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:
@@ -183,11 +183,22 @@ class Settings(BaseSettings):
|
||||
missing.append("RESEND_FROM")
|
||||
if not self.admin_emails or not self.admin_emails_list:
|
||||
missing.append("ADMIN_EMAILS")
|
||||
# Phase 5: the public contact form needs a destination inbox
|
||||
# and a real hCaptcha key pair. Missing any of these would
|
||||
# either drop submissions on the floor (no recipient) or open
|
||||
# the form to unverified bot traffic (no captcha), so we
|
||||
# enforce them at boot.
|
||||
if not self.admin_contact_email:
|
||||
missing.append("ADMIN_CONTACT_EMAIL")
|
||||
if not self.hcaptcha_secret:
|
||||
missing.append("HCAPTCHA_SECRET")
|
||||
if not self.hcaptcha_site_key:
|
||||
missing.append("HCAPTCHA_SITE_KEY")
|
||||
if missing:
|
||||
raise ValueError(
|
||||
"Production configuration is missing required values: "
|
||||
+ ", ".join(missing)
|
||||
+ ". These are needed for magic-link admin auth."
|
||||
+ ". These are needed for admin auth and the contact form."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
16
app/main.py
16
app/main.py
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
159
app/services/contact.py
Normal file
159
app/services/contact.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Contact-form persistence + notification orchestration.
|
||||
|
||||
Thin service wired at app startup:
|
||||
|
||||
- :meth:`ContactService.record_submission` — insert a row into
|
||||
``contact_submissions`` and return a mapped
|
||||
:class:`ContactSubmission` entity.
|
||||
- :meth:`ContactService.send_notification` — best-effort pass through
|
||||
to :class:`EmailService.send_contact_notification`. NEVER raises.
|
||||
|
||||
The route handler (``/contact``) short-circuits the spam path (honeypot
|
||||
tripped or hCaptcha failed) BEFORE calling either method, so anything
|
||||
that reaches this service has already passed the bot screen. The audit
|
||||
log is written by the route, not here — keeps this service free of
|
||||
request-scoped context (ip/ua are persisted on the row itself).
|
||||
|
||||
Security notes
|
||||
--------------
|
||||
- Parameterized SQL only (sqlalchemy ``text("...:param...")``).
|
||||
- Datetimes are timezone-aware UTC and serialized via ``.isoformat()``,
|
||||
matching the rest of the codebase.
|
||||
- Raw message bodies never flow through logs or audit detail; only
|
||||
``len(message)`` and a short preview (if any) appear in audit rows,
|
||||
and that is the route's responsibility.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
from app.config import Settings
|
||||
from app.models.entities import ContactSubmission
|
||||
from app.models.mappers import row_to_contact_submission
|
||||
from app.services.audit import AuditService
|
||||
from app.services.email import EmailService
|
||||
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ContactService:
|
||||
"""Persist contact submissions and trigger notification emails."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: Engine,
|
||||
email: EmailService,
|
||||
audit: AuditService,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
"""Store collaborators by reference."""
|
||||
self._engine: Engine = engine
|
||||
self._email: EmailService = email
|
||||
self._audit: AuditService = audit
|
||||
self._settings: Settings = settings
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# record_submission
|
||||
# ------------------------------------------------------------------
|
||||
def record_submission(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
email: str,
|
||||
message: str,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
) -> ContactSubmission:
|
||||
"""Insert a ``contact_submissions`` row and return the entity.
|
||||
|
||||
All inputs are taken at face value — validation is the route's
|
||||
responsibility. The mapper guarantees tz-aware UTC on read so
|
||||
callers can safely format ``submitted_at.isoformat()`` in
|
||||
downstream emails.
|
||||
"""
|
||||
submitted_at = datetime.now(timezone.utc)
|
||||
submitted_iso = submitted_at.isoformat()
|
||||
|
||||
with self._engine.begin() as conn:
|
||||
result = conn.execute(
|
||||
text(
|
||||
"INSERT INTO contact_submissions"
|
||||
" (name, email, message, ip, user_agent,"
|
||||
" submitted_at, handled)"
|
||||
" VALUES (:name, :email, :message, :ip, :user_agent,"
|
||||
" :submitted_at, 0)"
|
||||
),
|
||||
{
|
||||
"name": name,
|
||||
"email": email,
|
||||
"message": message,
|
||||
"ip": ip or "",
|
||||
"user_agent": user_agent or "",
|
||||
"submitted_at": submitted_iso,
|
||||
},
|
||||
)
|
||||
new_id = int(result.lastrowid) # type: ignore[arg-type]
|
||||
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT id, name, email, message, ip, user_agent,"
|
||||
" submitted_at, handled"
|
||||
" FROM contact_submissions WHERE id = :id"
|
||||
),
|
||||
{"id": new_id},
|
||||
).mappings().first()
|
||||
|
||||
# ``row`` is always present — we just inserted it in the same
|
||||
# transaction — but we type-guard defensively so mypy is happy.
|
||||
if row is None: # pragma: no cover — impossible path
|
||||
raise RuntimeError(
|
||||
"contact_submission row disappeared between insert and select"
|
||||
)
|
||||
return row_to_contact_submission(row)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# send_notification
|
||||
# ------------------------------------------------------------------
|
||||
def send_notification(self, submission: ContactSubmission) -> None:
|
||||
"""Dispatch the admin notification email; never raise.
|
||||
|
||||
Best-effort: any exception is caught and logged under
|
||||
``contact_send_failed`` so the caller can still render the
|
||||
success page. The submission row is already persisted when
|
||||
this method is invoked, so the admin has a DB trail even if
|
||||
the outbound email fails.
|
||||
|
||||
Silently no-ops when ``ADMIN_CONTACT_EMAIL`` is unset. That
|
||||
only happens in dev (production validator requires the field)
|
||||
and is logged for visibility.
|
||||
"""
|
||||
to = self._settings.admin_contact_email
|
||||
if not to:
|
||||
_log.info(
|
||||
"contact_notification_skipped_no_recipient",
|
||||
reason="admin_contact_email_unset",
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
self._email.send_contact_notification(
|
||||
to=to,
|
||||
submission_name=submission.name,
|
||||
submission_email=submission.email,
|
||||
message=submission.message,
|
||||
submitted_at=submission.submitted_at,
|
||||
ip=submission.ip,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
# The email service is itself defensive (it swallows Resend
|
||||
# errors internally). This belt-and-braces catch protects
|
||||
# the request path from any unexpected programming error.
|
||||
_log.exception(
|
||||
"contact_send_failed",
|
||||
submission_id=submission.id,
|
||||
)
|
||||
@@ -52,6 +52,84 @@ class EmailService:
|
||||
self._settings: Settings = settings
|
||||
self._templates: Jinja2Templates = templates
|
||||
|
||||
def send_contact_notification(
|
||||
self,
|
||||
*,
|
||||
to: str,
|
||||
submission_name: str,
|
||||
submission_email: str,
|
||||
message: str,
|
||||
submitted_at: datetime,
|
||||
ip: str,
|
||||
) -> None:
|
||||
"""Send the contact-form notification email to the admin inbox.
|
||||
|
||||
Behavior
|
||||
--------
|
||||
- If ``settings.resend_api_key`` (or ``resend_from``) is falsy,
|
||||
log a ``contact_notification_dev_fallback`` event at INFO and
|
||||
return. Dev convenience only — the production validator
|
||||
refuses to boot without a Resend key.
|
||||
- Otherwise render both template bodies, set ``Reply-To`` to the
|
||||
submitter's email (so Head Hen just hits reply), and dispatch
|
||||
via Resend. Transport errors are logged (``contact_notification_failed``)
|
||||
and never re-raised — the request path must always complete
|
||||
with the generic success page.
|
||||
|
||||
Subject format (fixed) matches the Phase 5 brief:
|
||||
``"New contact submission from {submission_name}"``.
|
||||
"""
|
||||
ctx = {
|
||||
"submission_name": submission_name,
|
||||
"submission_email": submission_email,
|
||||
"message": message,
|
||||
"submitted_at": submitted_at.isoformat(),
|
||||
"ip": ip or "",
|
||||
}
|
||||
html_body = self._render("emails/contact_notification.html", ctx)
|
||||
text_body = self._render("emails/contact_notification.txt", ctx)
|
||||
|
||||
api_key: Optional[str] = self._settings.resend_api_key
|
||||
sender: Optional[str] = self._settings.resend_from
|
||||
|
||||
# Dev fallback — log enough to confirm the flow reached the
|
||||
# email layer without echoing the whole message body at INFO.
|
||||
if not api_key or not sender:
|
||||
_log.info(
|
||||
"contact_notification_dev_fallback",
|
||||
to=to,
|
||||
submission_name=submission_name,
|
||||
submission_email=submission_email,
|
||||
message_length=len(message or ""),
|
||||
)
|
||||
return
|
||||
|
||||
subject = f"New contact submission from {submission_name}"
|
||||
try:
|
||||
import resend # type: ignore[import-untyped]
|
||||
|
||||
resend.api_key = api_key
|
||||
resend.Emails.send(
|
||||
{
|
||||
"from": sender,
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"html": html_body,
|
||||
"text": text_body,
|
||||
# Reply-To lets Head Hen reply directly from her
|
||||
# inbox without exposing the From: address to
|
||||
# public rewriting.
|
||||
"reply_to": submission_email,
|
||||
}
|
||||
)
|
||||
_log.info("contact_notification_sent", to=to)
|
||||
except Exception: # noqa: BLE001
|
||||
# Never raise from the request path — see docstring.
|
||||
_log.exception(
|
||||
"contact_notification_failed",
|
||||
to=to,
|
||||
)
|
||||
|
||||
def send_magic_link(
|
||||
self,
|
||||
*,
|
||||
|
||||
132
app/services/hcaptcha.py
Normal file
132
app/services/hcaptcha.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""hCaptcha server-side verification.
|
||||
|
||||
Wraps the hCaptcha ``siteverify`` endpoint with a dev-mode bypass so
|
||||
local work does not require a real site-key / secret pair.
|
||||
|
||||
Security and UX rules
|
||||
---------------------
|
||||
- **Never raise from the request path.** The caller (``/contact``)
|
||||
treats a ``False`` return as "reject the submission as spam" and a
|
||||
``True`` return as "continue to validation". Any network hiccup /
|
||||
non-200 / malformed-JSON surfaces as ``False`` — it is safer to drop
|
||||
a legitimate visitor's submission than to accept a spam flood if
|
||||
hCaptcha is temporarily unavailable.
|
||||
- **Dev fallback:** when ``settings.hcaptcha_secret`` is falsy we log a
|
||||
structured ``hcaptcha_dev_fallback`` event at INFO and return
|
||||
``True``. The production config validator refuses to boot without a
|
||||
secret, so this path only runs locally.
|
||||
- Raw tokens are single-use, short-lived, and bound to the submitter's
|
||||
session — we do not persist or log them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
# hCaptcha's server-side verify endpoint. Documented at
|
||||
# https://docs.hcaptcha.com/#verify-the-user-response-server-side.
|
||||
_SITEVERIFY_URL: str = "https://hcaptcha.com/siteverify"
|
||||
|
||||
# Bounded network timeout. hCaptcha's own advice is 5s; longer would
|
||||
# block the request path unnecessarily under an incident.
|
||||
_TIMEOUT_SECONDS: float = 5.0
|
||||
|
||||
|
||||
class HCaptchaService:
|
||||
"""Verify hCaptcha responses server-side.
|
||||
|
||||
Usage
|
||||
-----
|
||||
``await hcaptcha_service.verify(token, remote_ip)`` returns a bool.
|
||||
``True`` means "treat the request as human"; ``False`` means "reject".
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
"""Store settings by reference so rotation at runtime works."""
|
||||
self._settings: Settings = settings
|
||||
|
||||
async def verify(self, token: str, remote_ip: str) -> bool:
|
||||
"""Verify ``token`` against hCaptcha; return True on success.
|
||||
|
||||
Behavior
|
||||
--------
|
||||
- If ``settings.hcaptcha_secret`` is falsy → log
|
||||
``hcaptcha_dev_fallback`` and return ``True`` (dev).
|
||||
- Otherwise POST to ``/siteverify`` with a 5s timeout.
|
||||
- Non-200, network error, malformed JSON, or ``success=False``
|
||||
all return ``False``.
|
||||
|
||||
The method never raises — errors are logged and converted to
|
||||
``False`` so the caller can render the generic thank-you page
|
||||
without leaking internal state.
|
||||
"""
|
||||
secret: Optional[str] = self._settings.hcaptcha_secret
|
||||
if not secret:
|
||||
# Dev bypass. The config validator forbids this in
|
||||
# production, so reaching this branch is always a dev/test
|
||||
# condition and safe to trust.
|
||||
_log.info("hcaptcha_dev_fallback", remote_ip=remote_ip or "")
|
||||
return True
|
||||
|
||||
payload = {
|
||||
"secret": secret,
|
||||
"response": token or "",
|
||||
"remoteip": remote_ip or "",
|
||||
}
|
||||
|
||||
data = await self._post_siteverify(payload)
|
||||
if data is None:
|
||||
# Timeout / transport error / non-200 / parse failure —
|
||||
# already logged by the helper. Treat as spam.
|
||||
return False
|
||||
|
||||
success = bool(data.get("success", False))
|
||||
if not success:
|
||||
# Log error codes if present so operators can diagnose
|
||||
# misconfigured keys without exposing the token.
|
||||
_log.info(
|
||||
"hcaptcha_verify_failed",
|
||||
error_codes=list(data.get("error-codes") or []),
|
||||
)
|
||||
return success
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internals
|
||||
# ------------------------------------------------------------------
|
||||
async def _post_siteverify(self, payload: dict) -> Optional[dict[str, Any]]:
|
||||
"""POST to hCaptcha's verify endpoint and return parsed JSON.
|
||||
|
||||
Returns ``None`` on any failure (timeout, non-200, transport
|
||||
error, malformed JSON). Kept separate from :meth:`verify` so
|
||||
tests can monkeypatch the HTTP boundary without touching the
|
||||
decision logic.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT_SECONDS) as client:
|
||||
resp = await client.post(_SITEVERIFY_URL, data=payload)
|
||||
except httpx.HTTPError:
|
||||
# Network error / timeout. Do not raise; do not log the
|
||||
# payload (contains the secret).
|
||||
_log.exception("hcaptcha_request_failed")
|
||||
return None
|
||||
|
||||
if resp.status_code != 200:
|
||||
_log.info(
|
||||
"hcaptcha_non_200",
|
||||
status_code=resp.status_code,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError:
|
||||
_log.exception("hcaptcha_malformed_json")
|
||||
return None
|
||||
@@ -490,6 +490,26 @@ a:focus-visible {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* Phase 5: inline field errors + top-level banner. Scoped to the
|
||||
contact form so the red tone does not bleed into other forms. */
|
||||
.contact-form__field-error,
|
||||
.contact-form__error {
|
||||
margin: var(--space-1) 0 0;
|
||||
color: #8b2e2e;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.contact-form__error {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background-color: #fbe9e7;
|
||||
border: 1px solid #d9a8a1;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.contact-form__captcha {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* Generic button. */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
|
||||
43
app/templates/emails/contact_notification.html
Normal file
43
app/templates/emails/contact_notification.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{#
|
||||
HTML body for the admin contact-form notification email.
|
||||
|
||||
Deliberately plain — email clients are hostile to CSS. ``message`` is
|
||||
rendered inside a <pre> so newlines submitted by the visitor are
|
||||
preserved without us having to manually convert them to <br>s (which
|
||||
bleach would strip anyway).
|
||||
|
||||
Jinja2 autoescape is on for .html templates; every field below is
|
||||
rendered via ``{{ ... }}`` without ``| safe``, so user input cannot
|
||||
escape into the DOM.
|
||||
|
||||
Context:
|
||||
- submission_name : str
|
||||
- submission_email : str
|
||||
- message : str
|
||||
- submitted_at : ISO-8601 str
|
||||
- ip : str
|
||||
#}<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>New contact submission — Chicken Babies R Us</title>
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #2B3A42; max-width: 560px; margin: 0 auto; padding: 24px;">
|
||||
<h1 style="font-size: 20px; margin: 0 0 16px;">New contact submission</h1>
|
||||
<p style="margin: 0 0 8px;"><strong>Name:</strong> {{ submission_name }}</p>
|
||||
<p style="margin: 0 0 8px;">
|
||||
<strong>Email:</strong>
|
||||
<a href="mailto:{{ submission_email }}" style="color: #5D8AA8;">{{ submission_email }}</a>
|
||||
</p>
|
||||
<p style="margin: 0 0 8px;"><strong>Submitted at:</strong> {{ submitted_at }}</p>
|
||||
<p style="margin: 0 0 16px;"><strong>IP:</strong> {{ ip }}</p>
|
||||
|
||||
<h2 style="font-size: 16px; margin: 16px 0 8px;">Message</h2>
|
||||
<pre style="white-space: pre-wrap; word-break: break-word; font-family: inherit; font-size: 14px; background: #FAF3E7; padding: 12px 16px; border-radius: 6px; margin: 0;">{{ message }}</pre>
|
||||
|
||||
<p style="color: #6b7a80; font-size: 13px; margin-top: 24px;">
|
||||
Replying to this email will respond directly to the sender
|
||||
({{ submission_email }}).
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
17
app/templates/emails/contact_notification.txt
Normal file
17
app/templates/emails/contact_notification.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
{# Plaintext body for the admin contact-form notification email.
|
||||
Mirrors the HTML version so clients that reject HTML still get a
|
||||
readable message. #}New contact submission
|
||||
======================
|
||||
|
||||
Name: {{ submission_name }}
|
||||
Email: {{ submission_email }}
|
||||
Submitted at: {{ submitted_at }}
|
||||
IP: {{ ip }}
|
||||
|
||||
Message
|
||||
-------
|
||||
{{ message }}
|
||||
|
||||
--
|
||||
Replying to this email will respond directly to the sender
|
||||
({{ submission_email }}).
|
||||
@@ -1,18 +1,30 @@
|
||||
{#
|
||||
Contact page — Phase 1 version.
|
||||
Contact page — live Phase 5 form.
|
||||
|
||||
The form is deliberately inert: no `method`, no `action`, all inputs
|
||||
and the submit button carry the `disabled` attribute. A muted note
|
||||
explains the form is coming soon; if `ADMIN_CONTACT_EMAIL` is set in
|
||||
the environment we render a `mailto:` link above the form so visitors
|
||||
still have a way to reach the farm.
|
||||
POSTs to /contact. Honeypot + hCaptcha + SlowAPI rate-limit protect
|
||||
the endpoint. Every field carries an id/label pair for a11y and a
|
||||
maxlength/minlength to match the server-side validator — the HTML5
|
||||
attributes are a UX hint only, not the security boundary.
|
||||
|
||||
Phase 5 replaces this template with a working POST handler, hCaptcha,
|
||||
honeypot, and rate limiting.
|
||||
Honeypot:
|
||||
- The ``website`` field is wrapped in a .visually-hidden container
|
||||
marked ``aria-hidden="true"`` so assistive tech hides it too.
|
||||
- It is NOT ``required`` and has ``tabindex="-1"`` so a keyboard
|
||||
user can't accidentally focus it.
|
||||
- The server rejects any submission where the field is non-empty.
|
||||
|
||||
hCaptcha:
|
||||
- When ``hcaptcha_site_key`` is truthy the widget div + script tag
|
||||
render. When empty (dev) we skip them and rely on the dev-mode
|
||||
fallback in :class:`HCaptchaService`.
|
||||
|
||||
Context:
|
||||
- contact_email : str | None (from settings.admin_contact_email)
|
||||
- active_nav : "contact"
|
||||
- contact_email : str | None (from settings.admin_contact_email)
|
||||
- active_nav : "contact"
|
||||
- errors : dict[str, str] (field_name -> message)
|
||||
- form : dict[str, str] (prior submitted values)
|
||||
- form_error : str | None (top-level error banner)
|
||||
- hcaptcha_site_key : str | None (rendered when truthy)
|
||||
#}
|
||||
{% extends "public/base.html" %}
|
||||
|
||||
@@ -32,30 +44,45 @@
|
||||
|
||||
{% if contact_email %}
|
||||
<p class="contact-mailto">
|
||||
The easiest way to reach us right now is email:
|
||||
Prefer email? Reach us at
|
||||
<a href="mailto:{{ contact_email }}">{{ contact_email }}</a>.
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="contact-mailto contact-mailto--muted">
|
||||
A direct email address will be posted here soon.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<p class="contact-form__note" role="note">
|
||||
Secure contact form coming soon.
|
||||
</p>
|
||||
{% if form_error %}
|
||||
<p class="contact-form__error" role="alert">{{ form_error }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form class="contact-form"
|
||||
method="POST"
|
||||
action="/contact"
|
||||
aria-describedby="contact-form-note">
|
||||
{# Honeypot — visually hidden + aria-hidden so neither sighted
|
||||
users nor screen readers encounter it. Bots fill it in and
|
||||
get silently filed as spam. #}
|
||||
<div class="visually-hidden" aria-hidden="true">
|
||||
<label for="contact-website">Website</label>
|
||||
<input type="text"
|
||||
id="contact-website"
|
||||
name="website"
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
class="contact-hp"
|
||||
value="">
|
||||
</div>
|
||||
|
||||
{# action="" and no method = form cannot submit. Every input is
|
||||
disabled so screen readers and the keyboard both respect the
|
||||
"not-yet-available" state. #}
|
||||
<form class="contact-form" action="" aria-describedby="contact-form-note" novalidate>
|
||||
<div class="contact-form__field">
|
||||
<label for="contact-name">Name</label>
|
||||
<input type="text"
|
||||
id="contact-name"
|
||||
name="name"
|
||||
autocomplete="name"
|
||||
disabled>
|
||||
required
|
||||
maxlength="80"
|
||||
value="{{ (form.name if form else '') or '' }}">
|
||||
{% if errors and errors.name %}
|
||||
<p class="contact-form__field-error" role="alert">{{ errors.name }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="contact-form__field">
|
||||
@@ -64,7 +91,12 @@
|
||||
id="contact-email"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
disabled>
|
||||
required
|
||||
maxlength="254"
|
||||
value="{{ (form.email if form else '') or '' }}">
|
||||
{% if errors and errors.email %}
|
||||
<p class="contact-form__field-error" role="alert">{{ errors.email }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="contact-form__field">
|
||||
@@ -72,14 +104,31 @@
|
||||
<textarea id="contact-message"
|
||||
name="message"
|
||||
rows="6"
|
||||
disabled></textarea>
|
||||
required
|
||||
minlength="10"
|
||||
maxlength="4000">{{ (form.message if form else '') or '' }}</textarea>
|
||||
{% if errors and errors.message %}
|
||||
<p class="contact-form__field-error" role="alert">{{ errors.message }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if hcaptcha_site_key %}
|
||||
<div class="contact-form__captcha">
|
||||
<div class="h-captcha" data-sitekey="{{ hcaptcha_site_key }}"></div>
|
||||
</div>
|
||||
{% else %}
|
||||
{# hCaptcha disabled in dev — HCaptchaService returns True. #}
|
||||
{% endif %}
|
||||
|
||||
<div class="contact-form__actions">
|
||||
<button type="submit" class="btn btn--primary" disabled>
|
||||
<button type="submit" class="btn btn--primary">
|
||||
Send message
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
{% if hcaptcha_site_key %}
|
||||
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
31
app/templates/public/contact_sent.html
Normal file
31
app/templates/public/contact_sent.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{#
|
||||
Contact success page — rendered after a successful POST /contact
|
||||
OR after a silent spam rejection (honeypot tripped / hCaptcha
|
||||
failed). Copy MUST stay identical across those branches so a bot
|
||||
operator can't use the response body to distinguish "we accepted
|
||||
your message" from "we filed your message under spam".
|
||||
|
||||
Context:
|
||||
- active_nav : "contact"
|
||||
#}
|
||||
{% extends "public/base.html" %}
|
||||
|
||||
{% block title %}Message sent — Chicken Babies R Us{% endblock %}
|
||||
{% block meta_description %}Thanks for reaching out to Chicken Babies R Us.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">Thanks for reaching out</h1>
|
||||
</header>
|
||||
|
||||
<p>
|
||||
Your message is on its way to Head Hen. We'll get back to you as
|
||||
soon as the chickens let us.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a class="btn btn--primary" href="/">Back to the home page</a>
|
||||
</p>
|
||||
</article>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user