mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
Form Update
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
---
|
||||
/**
|
||||
* ObfuscatedEmail.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Renders an email address as a clickable link without ever putting the
|
||||
* plaintext address into the HTML source. The address is base64-encoded at
|
||||
* render time and stored in a data attribute; a tiny inline script decodes it
|
||||
* on click and builds the mailto: href at that moment. Scrapers that simply
|
||||
* grep the HTML for `@` or `mailto:` see nothing useful.
|
||||
*
|
||||
* This is not cryptographically secure obfuscation (base64 is trivially
|
||||
* reversible), but it defeats the vast majority of dumb harvesters that don't
|
||||
* run JS. Combined with robots.txt + noindex on /contact, it raises the cost
|
||||
* of harvesting enough to be worthwhile.
|
||||
*
|
||||
* <noscript> fallback shows a non-clickable prompt directing visitors to the
|
||||
* contact form, so the page isn't broken for the rare no-JS visitor.
|
||||
*
|
||||
* Usage: <ObfuscatedEmail address="contact@gregkilmer.com" />
|
||||
*/
|
||||
interface Props {
|
||||
address: string;
|
||||
/** Optional visible label; defaults to the address (decoded client-side). */
|
||||
label?: string;
|
||||
class?: string;
|
||||
style?: string;
|
||||
}
|
||||
|
||||
const { address, label, class: className, style } = Astro.props;
|
||||
|
||||
// Encode at render time. btoa needs a binary-safe string; email addresses are
|
||||
// ASCII so this is fine. We also reverse the string first so even a base64
|
||||
// decoder doesn't immediately yield the address — a small extra speed bump.
|
||||
const encoded = btoa(address.split("").reverse().join(""));
|
||||
const displayLabel = label ?? " "; // placeholder; JS fills the real address
|
||||
---
|
||||
|
||||
<a
|
||||
href="#"
|
||||
data-mail={encoded}
|
||||
class={className}
|
||||
style={style}
|
||||
onclick="return false;"
|
||||
aria-label="Email address (decoded on click)"
|
||||
>
|
||||
{displayLabel}
|
||||
</a>
|
||||
<noscript>
|
||||
<span class={className} style={style}>
|
||||
Enable JavaScript to see the email address, or use the contact form.
|
||||
</span>
|
||||
</noscript>
|
||||
|
||||
<script is:inline>
|
||||
// Decode and wire up every obfuscated email link on the page. Runs on initial
|
||||
// load and again after Astro View Transitions swap the DOM (astro:page-load).
|
||||
function initObfuscatedEmail() {
|
||||
document.querySelectorAll("a[data-mail]").forEach((el) => {
|
||||
const a = el;
|
||||
const encoded = a.getAttribute("data-mail");
|
||||
if (!encoded) return;
|
||||
// Reverse the base64 we encoded at render time, then decode.
|
||||
const reversed = atob(encoded).split("").reverse().join("");
|
||||
a.setAttribute("href", "mailto:" + reversed);
|
||||
// If no explicit label was provided, show the address as the link text.
|
||||
if (!a.textContent || a.textContent.trim() === "") {
|
||||
a.textContent = reversed;
|
||||
}
|
||||
});
|
||||
}
|
||||
initObfuscatedEmail();
|
||||
document.addEventListener("astro:page-load", initObfuscatedEmail);
|
||||
</script>
|
||||
@@ -4,12 +4,18 @@
|
||||
* Accessible contact form island. Posts to /api/contact, which either sends an
|
||||
* email via SMTP or fires a webhook. Manages idle / submitting / success / error
|
||||
* states, does lightweight client validation, and includes a honeypot field for
|
||||
* crude spam defense (the server also rate-limits).
|
||||
* crude spam defense (the server also rate-limits and verifies a Turnstile
|
||||
* token when configured).
|
||||
*
|
||||
* The form is keyboard-accessible, labels every field, and announces status
|
||||
* changes via an aria-live region.
|
||||
*
|
||||
* Turnstile: when `turnstileSiteKey` is provided, Cloudflare's invisible
|
||||
* challenge is rendered. The token it produces is sent with the submission and
|
||||
* verified server-side. If no key is configured (local dev), Turnstile is
|
||||
* skipped entirely so the form still works.
|
||||
*/
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useState, useRef, useEffect, type FormEvent } from 'react';
|
||||
|
||||
type Status = 'idle' | 'submitting' | 'success' | 'error';
|
||||
|
||||
@@ -21,10 +27,77 @@ interface FieldErrors {
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export default function ContactForm() {
|
||||
// Declared by the Turnstile script on window once loaded.
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: {
|
||||
render: (el: string | HTMLElement, opts: Record<string, unknown>) => string;
|
||||
reset: (id?: string) => void;
|
||||
remove: (id: string) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Public Cloudflare Turnstile site key. Omit to skip Turnstile (e.g. dev). */
|
||||
turnstileSiteKey?: string;
|
||||
}
|
||||
|
||||
export default function ContactForm({ turnstileSiteKey }: Props) {
|
||||
const [status, setStatus] = useState<Status>('idle');
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [errorMsg, setErrorMsg] = useState<string>('');
|
||||
const [turnstileReady, setTurnstileReady] = useState<boolean>(!turnstileSiteKey);
|
||||
|
||||
// Holds the live Turnstile token between renders (cleared on reset/expiry).
|
||||
const turnstileTokenRef = useRef<string>('');
|
||||
// Widget ID returned by turnstile.render(); used to reset on failure.
|
||||
const turnstileWidgetIdRef = useRef<string | null>(null);
|
||||
// Container div the widget mounts into.
|
||||
const turnstileContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Load the Turnstile script once when a site key is configured.
|
||||
useEffect(() => {
|
||||
if (!turnstileSiteKey) return;
|
||||
if (document.getElementById('cf-turnstile-script')) return;
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.id = 'cf-turnstile-script';
|
||||
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
document.head.appendChild(script);
|
||||
}, [turnstileSiteKey]);
|
||||
|
||||
// Once the script + container are present, render the widget.
|
||||
useEffect(() => {
|
||||
if (!turnstileSiteKey) return;
|
||||
if (!window.turnstile || !turnstileContainerRef.current) return;
|
||||
|
||||
// Avoid double-rendering if the effect fires twice in StrictMode/dev.
|
||||
if (turnstileWidgetIdRef.current) return;
|
||||
|
||||
turnstileWidgetIdRef.current = window.turnstile.render(
|
||||
turnstileContainerRef.current,
|
||||
{
|
||||
sitekey: turnstileSiteKey,
|
||||
// Called by Turnstile once it has a token (success) or on expiry.
|
||||
callback: (token: string) => {
|
||||
turnstileTokenRef.current = token;
|
||||
setTurnstileReady(true);
|
||||
},
|
||||
'expired-callback': () => {
|
||||
turnstileTokenRef.current = '';
|
||||
setTurnstileReady(false);
|
||||
},
|
||||
'error-callback': () => {
|
||||
turnstileTokenRef.current = '';
|
||||
setTurnstileReady(false);
|
||||
},
|
||||
theme: 'dark',
|
||||
},
|
||||
);
|
||||
}, [turnstileSiteKey]);
|
||||
|
||||
function validate(values: { name: string; email: string; message: string }): FieldErrors {
|
||||
const e: FieldErrors = {};
|
||||
@@ -46,9 +119,15 @@ export default function ContactForm() {
|
||||
message: String(data.get('message') ?? '').trim(),
|
||||
// Honeypot: bots fill this, humans never see it.
|
||||
company: String(data.get('company') ?? ''),
|
||||
// Turnstile token (empty string if Turnstile is not configured).
|
||||
turnstileToken: turnstileTokenRef.current,
|
||||
};
|
||||
|
||||
const found = validate(values);
|
||||
// If Turnstile is configured, require a token before we even hit the server.
|
||||
if (turnstileSiteKey && !values.turnstileToken) {
|
||||
found.message = found.message ?? 'Please complete the bot check and try again.';
|
||||
}
|
||||
setErrors(found);
|
||||
if (Object.keys(found).length > 0) return;
|
||||
|
||||
@@ -62,10 +141,22 @@ export default function ContactForm() {
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
// Reset the Turnstile widget so the user can re-verify after a failure.
|
||||
if (turnstileSiteKey && turnstileWidgetIdRef.current) {
|
||||
window.turnstile?.reset(turnstileWidgetIdRef.current);
|
||||
turnstileTokenRef.current = '';
|
||||
setTurnstileReady(false);
|
||||
}
|
||||
throw new Error(body.error || 'Something went wrong. Please try again.');
|
||||
}
|
||||
setStatus('success');
|
||||
form.reset();
|
||||
// Fresh widget for the next submission.
|
||||
if (turnstileSiteKey && turnstileWidgetIdRef.current) {
|
||||
window.turnstile?.reset(turnstileWidgetIdRef.current);
|
||||
turnstileTokenRef.current = '';
|
||||
setTurnstileReady(false);
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
setErrorMsg(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
|
||||
@@ -176,9 +267,16 @@ export default function ContactForm() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Cloudflare Turnstile widget. Rendered by the script via the ref.
|
||||
Hidden container; Turnstile injects its iframe here. Skipped entirely
|
||||
when no site key is configured (turnstileSiteKey is undefined). */}
|
||||
{turnstileSiteKey && (
|
||||
<div ref={turnstileContainerRef} className="min-h-[65px]" aria-label="Bot check" />
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === 'submitting'}
|
||||
disabled={status === 'submitting' || (Boolean(turnstileSiteKey) && !turnstileReady)}
|
||||
className="btn btn-primary self-start disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{status === 'submitting' ? 'Sending…' : 'Send message'}
|
||||
|
||||
Reference in New Issue
Block a user