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:
@@ -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