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:
+17
-7
@@ -19,20 +19,30 @@ SITE_URL=https://gregkilmer.com
|
|||||||
# neither is set, the API returns 503 so you can wire delivery later.
|
# neither is set, the API returns 503 so you can wire delivery later.
|
||||||
|
|
||||||
# SMTP (nodemailer). CONTACT_TO is the inbox that receives submissions.
|
# SMTP (nodemailer). CONTACT_TO is the inbox that receives submissions.
|
||||||
# SMTP_HOST=
|
# Point this at your homelab SMTP relay (Postfix/Mailcow/etc.). The relay must
|
||||||
# SMTP_PORT=587
|
# have SPF/DKIM/DMARC configured or mail will land in spam. Leave SMTP_HOST blank
|
||||||
# SMTP_USER=
|
# to disable delivery (the API returns 503 until this is set).
|
||||||
# SMTP_PASS=
|
SMTP_HOST=
|
||||||
# CONTACT_TO=greg@gregkilmer.com
|
SMTP_PORT=587
|
||||||
# SMTP_FROM="Greg's Site <no-reply@gregkilmer.com>"
|
SMTP_USER=
|
||||||
|
SMTP_PASS=
|
||||||
|
CONTACT_TO=contact@gregkilmer.com
|
||||||
|
SMTP_FROM="Greg's Site <no-reply@gregkilmer.com>"
|
||||||
|
|
||||||
# Optional webhook fallback (JSON POST).
|
# Optional webhook fallback (JSON POST). Only used if SMTP_HOST is unset.
|
||||||
# CONTACT_WEBHOOK_URL=
|
# CONTACT_WEBHOOK_URL=
|
||||||
|
|
||||||
# Honeypot / basic abuse control. Maximum submissions per IP per window.
|
# Honeypot / basic abuse control. Maximum submissions per IP per window.
|
||||||
CONTACT_RATE_LIMIT=5
|
CONTACT_RATE_LIMIT=5
|
||||||
CONTACT_RATE_WINDOW_MIN=15
|
CONTACT_RATE_WINDOW_MIN=15
|
||||||
|
|
||||||
|
# --- Cloudflare Turnstile (bot protection) ----------------------------------
|
||||||
|
# Get free keys at: https://dash.cloudflare.com/?to=/:account/turnstile
|
||||||
|
# SITE_KEY is public (ships to the client); SECRET_KEY is server-only.
|
||||||
|
# Leave both blank to skip Turnstile entirely (e.g. local dev).
|
||||||
|
TURNSTILE_SITE_KEY=
|
||||||
|
TURNSTILE_SECRET_KEY=
|
||||||
|
|
||||||
# --- Gaming box-art tool ----------------------------------------------------
|
# --- Gaming box-art tool ----------------------------------------------------
|
||||||
# Used by scripts/fetch-box-art.mjs to download box/banner art for Gaming Hall
|
# Used by scripts/fetch-box-art.mjs to download box/banner art for Gaming Hall
|
||||||
# of Fame entries via the RAWG API. Get a free key at:
|
# of Fame entries via the RAWG API. Get a free key at:
|
||||||
|
|||||||
+60
-1
@@ -25,9 +25,10 @@ delivery, set **either** SMTP vars **or** `CONTACT_WEBHOOK_URL` (see
|
|||||||
| `SITE_URL` | Canonical URL for metadata | `https://gregkilmer.com` |
|
| `SITE_URL` | Canonical URL for metadata | `https://gregkilmer.com` |
|
||||||
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` | SMTP relay | unset |
|
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` | SMTP relay | unset |
|
||||||
| `SMTP_FROM` | From address for outgoing mail | derived from `SITE_URL` |
|
| `SMTP_FROM` | From address for outgoing mail | derived from `SITE_URL` |
|
||||||
| `CONTACT_TO` | Inbox that receives submissions | `greg@gregkilmer.com` |
|
| `CONTACT_TO` | Inbox that receives submissions | `contact@gregkilmer.com` |
|
||||||
| `CONTACT_WEBHOOK_URL` | JSON webhook fallback (Discord/Slack/n8n) | unset |
|
| `CONTACT_WEBHOOK_URL` | JSON webhook fallback (Discord/Slack/n8n) | unset |
|
||||||
| `CONTACT_RATE_LIMIT` / `CONTACT_RATE_WINDOW_MIN` | Per-IP rate limit | `5` / `15` |
|
| `CONTACT_RATE_LIMIT` / `CONTACT_RATE_WINDOW_MIN` | Per-IP rate limit | `5` / `15` |
|
||||||
|
| `TURNSTILE_SITE_KEY` / `TURNSTILE_SECRET_KEY` | Cloudflare Turnstile bot protection | unset (skipped) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -102,14 +103,72 @@ path:
|
|||||||
3. **Neither configured**: returns `503` with a friendly "email me directly"
|
3. **Neither configured**: returns `503` with a friendly "email me directly"
|
||||||
message so the form never silently swallows input.
|
message so the form never silently swallows input.
|
||||||
|
|
||||||
|
### SMTP relay DNS requirements (homelab)
|
||||||
|
|
||||||
|
When pointing the form at a homelab SMTP relay (Postfix, Mailcow, etc.), the
|
||||||
|
relay itself must have proper DNS records or mail **will** land in recipients'
|
||||||
|
spam folders — this is the relay's responsibility, not the site's. Ensure the
|
||||||
|
sending domain (the host part of `SMTP_FROM`, or the relay's HELO name) has:
|
||||||
|
|
||||||
|
- **SPF** (`TXT` on the domain): lists the relay's IP as an authorized sender.
|
||||||
|
- **DKIM** (`TXT` on `selector._domainkey`): signs outgoing messages so the
|
||||||
|
relay's identity can be verified.
|
||||||
|
- **DMARC** (`TXT` on `_dmarc`): tells receivers how to handle SPF/DKIM
|
||||||
|
failures (start at `p=none` to monitor, tighten to `quarantine` later).
|
||||||
|
- **rDNS / PTR** on the relay's public IP: must match the relay's HELO name.
|
||||||
|
|
||||||
|
`SMTP_FROM` should use a domain you control (e.g. `no-reply@gregkilmer.com`),
|
||||||
|
not `gmail.com` — SPF on a domain you don't own will reject or sink the mail.
|
||||||
|
|
||||||
|
Test delivery + spam score with [mail-tester.com](https://www.mail-tester.com/)
|
||||||
|
(send a form submission to the unique address it gives you).
|
||||||
|
|
||||||
|
### Cloudflare Turnstile (bot protection)
|
||||||
|
|
||||||
|
The form includes an invisible Cloudflare Turnstile challenge. To enable it:
|
||||||
|
|
||||||
|
1. Create a widget at
|
||||||
|
[Cloudflare Turnstile](https://dash.cloudflare.com/?to=/:account/turnstile).
|
||||||
|
Use the **Managed** challenge mode (invisible, no puzzle).
|
||||||
|
2. Put the **site key** in `TURNSTILE_SITE_KEY` and the **secret key** in
|
||||||
|
`TURNSTILE_SECRET_KEY` (both in `.env`).
|
||||||
|
3. The site key is public and ships to the browser; the secret stays
|
||||||
|
server-side. The API verifies each token against Cloudflare's `siteverify`
|
||||||
|
endpoint before sending mail.
|
||||||
|
|
||||||
|
If neither key is set, Turnstile is skipped entirely (useful for local dev —
|
||||||
|
the form works without a challenge). The honeypot + per-IP rate limit still
|
||||||
|
apply regardless.
|
||||||
|
|
||||||
Test it:
|
Test it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# With Turnstile configured, this is rejected (no/bad token) with 400:
|
||||||
|
curl -X POST http://localhost:4321/api/contact \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"Test","email":"test@example.com","message":"Hello there."}'
|
||||||
|
|
||||||
|
# Without Turnstile (keys unset), this succeeds and delivers mail:
|
||||||
curl -X POST http://localhost:4321/api/contact \
|
curl -X POST http://localhost:4321/api/contact \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"name":"Test","email":"test@example.com","message":"Hello there."}'
|
-d '{"name":"Test","email":"test@example.com","message":"Hello there."}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Anti-scraping
|
||||||
|
|
||||||
|
The site applies a layered anti-scraping policy:
|
||||||
|
|
||||||
|
- **`public/robots.txt`** allows legitimate search engines (Google, Bing) but
|
||||||
|
blocks known AI training bots (GPTBot, ClaudeBot, CCBot, Bytespider, etc.)
|
||||||
|
and disallows `/api/` and `/contact` for everyone. Note: robots.txt is
|
||||||
|
voluntary; determined scrapers ignore it.
|
||||||
|
- **`noindex` on `/contact`**: the contact page emits
|
||||||
|
`<meta name="robots" content="noindex, nofollow">` so it won't be indexed
|
||||||
|
even if a bot reaches it.
|
||||||
|
- **Email obfuscation**: the Gmail address is never in the HTML source as
|
||||||
|
plaintext; it's base64-encoded and decoded to a `mailto:` client-side on
|
||||||
|
click. Dumb harvesters that grep for `@` or `mailto:` find nothing.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Updating the site
|
## 5. Updating the site
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# robots.txt — gregkilmer.com
|
||||||
|
# =============================================================================
|
||||||
|
# Strict anti-scraping policy.
|
||||||
|
#
|
||||||
|
# - Google/Bing/etc. (legitimate search) are ALLOWED for SEO.
|
||||||
|
# - /api/ and /contact are disallowed for everyone (the form + endpoints
|
||||||
|
# should not be indexed or crawled).
|
||||||
|
# - Known AI training bots are blocked by User-Agent below. Note that
|
||||||
|
# robots.txt is voluntary; determined scrapers will ignore it. This is a
|
||||||
|
# first layer (paired with noindex on /contact and email obfuscation).
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- Default policy: allow crawling of public pages, block the API + contact
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
Disallow: /api/
|
||||||
|
Disallow: /contact
|
||||||
|
|
||||||
|
# --- Block known AI training / scraping bots explicitly ---------------------
|
||||||
|
# OpenAI
|
||||||
|
User-agent: GPTBot
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: ChatGPT-User
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: OAI-SearchBot
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
# Anthropic
|
||||||
|
User-agent: ClaudeBot
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: Claude-Web
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: anthropic-ai
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
# Google's AI/Vertex crawlers (separate from regular Googlebot, which is allowed)
|
||||||
|
User-agent: Google-Extended
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
# Common Crawl (feeds many AI training datasets)
|
||||||
|
User-agent: CCBot
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
# ByteDance / TikTok
|
||||||
|
User-agent: Bytespider
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
# Meta
|
||||||
|
User-agent: meta-externalagent
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: Meta-ExternalAgent
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: FacebookBot
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
# Amazon / Alexa
|
||||||
|
User-agent: Amazonbot
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
# Apple's AI/Applebot-Extended (regular Applebot remains allowed for Siri/Suggestions)
|
||||||
|
User-agent: Applebot-Extended
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
# Perplexity
|
||||||
|
User-agent: PerplexityBot
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
# Cohere
|
||||||
|
User-agent: cohere-ai
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
# Diffbot
|
||||||
|
User-agent: Diffbot
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
# Imagesift / OC-72 / other image scrapers
|
||||||
|
User-agent: ImagesiftBot
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: omgili
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: Omgilibot
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
# Sitemap (uncomment once a sitemap.xml is generated)
|
||||||
|
# Sitemap: https://gregkilmer.com/sitemap.xml
|
||||||
@@ -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
|
* Accessible contact form island. Posts to /api/contact, which either sends an
|
||||||
* email via SMTP or fires a webhook. Manages idle / submitting / success / error
|
* email via SMTP or fires a webhook. Manages idle / submitting / success / error
|
||||||
* states, does lightweight client validation, and includes a honeypot field for
|
* 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
|
* The form is keyboard-accessible, labels every field, and announces status
|
||||||
* changes via an aria-live region.
|
* 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';
|
type Status = 'idle' | 'submitting' | 'success' | 'error';
|
||||||
|
|
||||||
@@ -21,10 +27,77 @@ interface FieldErrors {
|
|||||||
|
|
||||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
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 [status, setStatus] = useState<Status>('idle');
|
||||||
const [errors, setErrors] = useState<FieldErrors>({});
|
const [errors, setErrors] = useState<FieldErrors>({});
|
||||||
const [errorMsg, setErrorMsg] = useState<string>('');
|
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 {
|
function validate(values: { name: string; email: string; message: string }): FieldErrors {
|
||||||
const e: FieldErrors = {};
|
const e: FieldErrors = {};
|
||||||
@@ -46,9 +119,15 @@ export default function ContactForm() {
|
|||||||
message: String(data.get('message') ?? '').trim(),
|
message: String(data.get('message') ?? '').trim(),
|
||||||
// Honeypot: bots fill this, humans never see it.
|
// Honeypot: bots fill this, humans never see it.
|
||||||
company: String(data.get('company') ?? ''),
|
company: String(data.get('company') ?? ''),
|
||||||
|
// Turnstile token (empty string if Turnstile is not configured).
|
||||||
|
turnstileToken: turnstileTokenRef.current,
|
||||||
};
|
};
|
||||||
|
|
||||||
const found = validate(values);
|
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);
|
setErrors(found);
|
||||||
if (Object.keys(found).length > 0) return;
|
if (Object.keys(found).length > 0) return;
|
||||||
|
|
||||||
@@ -62,10 +141,22 @@ export default function ContactForm() {
|
|||||||
});
|
});
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
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.');
|
throw new Error(body.error || 'Something went wrong. Please try again.');
|
||||||
}
|
}
|
||||||
setStatus('success');
|
setStatus('success');
|
||||||
form.reset();
|
form.reset();
|
||||||
|
// Fresh widget for the next submission.
|
||||||
|
if (turnstileSiteKey && turnstileWidgetIdRef.current) {
|
||||||
|
window.turnstile?.reset(turnstileWidgetIdRef.current);
|
||||||
|
turnstileTokenRef.current = '';
|
||||||
|
setTurnstileReady(false);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setErrorMsg(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
|
setErrorMsg(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
|
||||||
@@ -176,9 +267,16 @@ export default function ContactForm() {
|
|||||||
</p>
|
</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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={status === 'submitting'}
|
disabled={status === 'submitting' || (Boolean(turnstileSiteKey) && !turnstileReady)}
|
||||||
className="btn btn-primary self-start disabled:cursor-not-allowed disabled:opacity-60"
|
className="btn btn-primary self-start disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{status === 'submitting' ? 'Sending…' : 'Send message'}
|
{status === 'submitting' ? 'Sending…' : 'Send message'}
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ interface Props {
|
|||||||
surface?: Surface;
|
surface?: Surface;
|
||||||
/** Override default OG image path. */
|
/** Override default OG image path. */
|
||||||
ogImage?: string;
|
ogImage?: string;
|
||||||
|
/** When true, emit <meta name="robots" content="noindex, nofollow">. Use on
|
||||||
|
pages that shouldn't be indexed (e.g. /contact, to reduce scraping). */
|
||||||
|
noindex?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -41,6 +44,7 @@ const {
|
|||||||
description = "Greg Kilmer is a software developer, maker, and tabletop gamemaster based in Philadelphia. This is where his professional and personal worlds meet.",
|
description = "Greg Kilmer is a software developer, maker, and tabletop gamemaster based in Philadelphia. This is where his professional and personal worlds meet.",
|
||||||
surface = "neutral",
|
surface = "neutral",
|
||||||
ogImage,
|
ogImage,
|
||||||
|
noindex = false,
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
// True on the Home page. Used to switch the NavBar's view-transition-name so
|
// True on the Home page. Used to switch the NavBar's view-transition-name so
|
||||||
@@ -79,6 +83,7 @@ const ogImageUrl = ogImage
|
|||||||
<title>{fullTitle}</title>
|
<title>{fullTitle}</title>
|
||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
<link rel="canonical" href={canonical} />
|
<link rel="canonical" href={canonical} />
|
||||||
|
{noindex && <meta name="robots" content="noindex, nofollow" />}
|
||||||
<meta name="theme-color" content="#0b1026" />
|
<meta name="theme-color" content="#0b1026" />
|
||||||
|
|
||||||
<!-- Open Graph / social cards -->
|
<!-- Open Graph / social cards -->
|
||||||
|
|||||||
@@ -5,9 +5,10 @@
|
|||||||
* 1. Reject non-POST.
|
* 1. Reject non-POST.
|
||||||
* 2. Validate JSON body with Zod (name, email, message; honeypot must be empty).
|
* 2. Validate JSON body with Zod (name, email, message; honeypot must be empty).
|
||||||
* 3. Rate-limit per IP (in-memory windowed counter).
|
* 3. Rate-limit per IP (in-memory windowed counter).
|
||||||
* 4. Deliver: SMTP via nodemailer if configured, else POST to a webhook if
|
* 4. Verify Cloudflare Turnstile token (if TURNSTILE_SECRET_KEY is set).
|
||||||
|
* 5. Deliver: SMTP via nodemailer if configured, else POST to a webhook if
|
||||||
* configured, else return 503 so delivery can be wired later.
|
* configured, else return 503 so delivery can be wired later.
|
||||||
* 5. Always return JSON { ok } or { error }.
|
* 6. Always return JSON { ok } or { error }.
|
||||||
*
|
*
|
||||||
* Secrets come from env (see .env.example). Nothing secret ships to the client.
|
* Secrets come from env (see .env.example). Nothing secret ships to the client.
|
||||||
* This runs server-side only via the @astrojs/node adapter.
|
* This runs server-side only via the @astrojs/node adapter.
|
||||||
@@ -22,6 +23,9 @@ const schema = z.object({
|
|||||||
message: z.string().min(10).max(5000),
|
message: z.string().min(10).max(5000),
|
||||||
// Honeypot: must be absent/empty. Bots tend to fill every field.
|
// Honeypot: must be absent/empty. Bots tend to fill every field.
|
||||||
company: z.string().max(0).optional().or(z.literal('')),
|
company: z.string().max(0).optional().or(z.literal('')),
|
||||||
|
// Turnstile token. Required when TURNSTILE_SECRET_KEY is set; ignored
|
||||||
|
// otherwise (dev). Validated against Cloudflare's siteverify endpoint below.
|
||||||
|
turnstileToken: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- In-memory rate limiter (per worker) ------------------------------------
|
// --- In-memory rate limiter (per worker) ------------------------------------
|
||||||
@@ -57,6 +61,36 @@ function clientIp(request: Request): string {
|
|||||||
return request.headers.get('x-real-ip') ?? 'unknown';
|
return request.headers.get('x-real-ip') ?? 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Turnstile verification -------------------------------------------------
|
||||||
|
// Skipped entirely when TURNSTILE_SECRET_KEY is unset (local dev). When set,
|
||||||
|
// the token from the client is POSTed to Cloudflare's siteverify endpoint
|
||||||
|
// along with the visitor's IP for fraud scoring. Only valid tokens proceed.
|
||||||
|
async function verifyTurnstile(token: string | undefined, ip: string): Promise<boolean> {
|
||||||
|
const secret = process.env.TURNSTILE_SECRET_KEY;
|
||||||
|
if (!secret) return true; // Turnstile not configured -> skip.
|
||||||
|
|
||||||
|
if (!token) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const form = new URLSearchParams();
|
||||||
|
form.set('secret', secret);
|
||||||
|
form.set('response', token);
|
||||||
|
if (ip && ip !== 'unknown') form.set('remoteip', ip);
|
||||||
|
|
||||||
|
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as { success: boolean };
|
||||||
|
return Boolean(data.success);
|
||||||
|
} catch (err) {
|
||||||
|
// Network error talking to Cloudflare. Fail closed: reject the submission
|
||||||
|
// rather than letting a bot through on a transient outage.
|
||||||
|
console.error('[contact] turnstile verification failed:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Route handler ----------------------------------------------------------
|
// --- Route handler ----------------------------------------------------------
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
if (request.method !== 'POST') {
|
if (request.method !== 'POST') {
|
||||||
@@ -74,7 +108,7 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return json({ error: 'Please complete all fields correctly.', issues: parsed.error.issues }, 400);
|
return json({ error: 'Please complete all fields correctly.', issues: parsed.error.issues }, 400);
|
||||||
}
|
}
|
||||||
const { name, email, message } = parsed.data;
|
const { name, email, message, turnstileToken } = parsed.data;
|
||||||
|
|
||||||
// Rate limit.
|
// Rate limit.
|
||||||
const ip = clientIp(request);
|
const ip = clientIp(request);
|
||||||
@@ -82,6 +116,13 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
return json({ error: 'Too many submissions. Please try again later.' }, 429);
|
return json({ error: 'Too many submissions. Please try again later.' }, 429);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Turnstile. Skipped when no secret is configured (dev). Fails closed on
|
||||||
|
// network errors so a Cloudflare outage can't open the door to bots.
|
||||||
|
const turnstileOk = await verifyTurnstile(turnstileToken, ip);
|
||||||
|
if (!turnstileOk) {
|
||||||
|
return json({ error: 'Bot check failed. Please refresh and try again.' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
// Deliver.
|
// Deliver.
|
||||||
try {
|
try {
|
||||||
const delivered = await deliver({ name, email, message });
|
const delivered = await deliver({ name, email, message });
|
||||||
|
|||||||
+41
-7
@@ -16,14 +16,13 @@ import Icon from "@components/Icon.astro";
|
|||||||
import SectionHeader from "@components/layout/SectionHeader.astro";
|
import SectionHeader from "@components/layout/SectionHeader.astro";
|
||||||
import Placeholder from "@components/layout/Placeholder.astro";
|
import Placeholder from "@components/layout/Placeholder.astro";
|
||||||
import ContactForm from "@components/professional/ContactForm.tsx";
|
import ContactForm from "@components/professional/ContactForm.tsx";
|
||||||
|
import ObfuscatedEmail from "@components/ObfuscatedEmail.astro";
|
||||||
|
|
||||||
|
// Email is rendered separately via <ObfuscatedEmail> so the plaintext address
|
||||||
|
// never appears in the HTML source. The other entries are plain links.
|
||||||
|
const emailAddress = "contact@gregkilmer.com";
|
||||||
|
|
||||||
const professional = [
|
const professional = [
|
||||||
{
|
|
||||||
icon: "email-outline",
|
|
||||||
label: "Email",
|
|
||||||
href: "",
|
|
||||||
hint: "Kilmer.GM@Gmail.com",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
icon: "linkedin",
|
icon: "linkedin",
|
||||||
label: "LinkedIn",
|
label: "LinkedIn",
|
||||||
@@ -56,11 +55,19 @@ const social = [
|
|||||||
hint: "Server link, if public.",
|
hint: "Server link, if public.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Turnstile site key. Public per Cloudflare (safe to ship to the client), but
|
||||||
|
// read here server-side and passed as a prop so it never needs the PUBLIC_
|
||||||
|
// prefix and stays consistent with TURNSTILE_SECRET_KEY. Undefined when
|
||||||
|
// Turnstile is not configured, which makes ContactForm skip the widget entirely.
|
||||||
|
const turnstileSiteKey = import.meta.env.TURNSTILE_SITE_KEY as
|
||||||
|
string | undefined;
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout
|
<BaseLayout
|
||||||
title="Contact"
|
title="Contact"
|
||||||
description="Reach Greg Kilmer for cloud development, SAP BTP, technical leadership, or just to debate the best Gundam series or find a fourth for game night."
|
description="Reach Greg Kilmer for cloud development, SAP BTP, technical leadership, or just to debate the best Gundam series or find a fourth for game night."
|
||||||
|
noindex={true}
|
||||||
>
|
>
|
||||||
<section class="container-prose flex flex-col gap-10 pt-28 sm:pt-32">
|
<section class="container-prose flex flex-col gap-10 pt-28 sm:pt-32">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
@@ -86,6 +93,33 @@ const social = [
|
|||||||
Professional
|
Professional
|
||||||
</h2>
|
</h2>
|
||||||
<ul class="flex flex-col gap-3">
|
<ul class="flex flex-col gap-3">
|
||||||
|
{
|
||||||
|
/* Email: rendered via ObfuscatedEmail so the address is not in
|
||||||
|
the HTML source. JS decodes it into a mailto: on click. */
|
||||||
|
}
|
||||||
|
<li class="card flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
|
||||||
|
style="background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent);"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Icon name="email-outline" class="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<span class="flex flex-col gap-0.5">
|
||||||
|
<span
|
||||||
|
class="font-sans text-sm font-semibold"
|
||||||
|
style="color: var(--color-text);"
|
||||||
|
>
|
||||||
|
Email
|
||||||
|
</span>
|
||||||
|
<span class="text-xs" style="color: var(--color-text-subtle);">
|
||||||
|
<ObfuscatedEmail
|
||||||
|
address={emailAddress}
|
||||||
|
class="font-mono uppercase tracking-[0.1em]"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
{
|
{
|
||||||
professional.map((item) => (
|
professional.map((item) => (
|
||||||
<li class="card flex items-center gap-3">
|
<li class="card flex items-center gap-3">
|
||||||
@@ -193,7 +227,7 @@ const social = [
|
|||||||
Prefer a form? Drop a note and it will land in my inbox.
|
Prefer a form? Drop a note and it will land in my inbox.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ContactForm client:idle />
|
<ContactForm client:idle turnstileSiteKey={turnstileSiteKey} />
|
||||||
</section>
|
</section>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import AlchemicalDivider from "@components/mystic/AlchemicalDivider.astro";
|
|||||||
import Astrolabe from "@components/mystic/Astrolabe.astro";
|
import Astrolabe from "@components/mystic/Astrolabe.astro";
|
||||||
import CircuitField from "@components/mystic/CircuitField.tsx";
|
import CircuitField from "@components/mystic/CircuitField.tsx";
|
||||||
import CelestialFrame from "@components/mystic/CelestialFrame.astro";
|
import CelestialFrame from "@components/mystic/CelestialFrame.astro";
|
||||||
|
import ObfuscatedEmail from "@components/ObfuscatedEmail.astro";
|
||||||
import { getCollection } from "astro:content";
|
import { getCollection } from "astro:content";
|
||||||
|
|
||||||
const experience = (await getCollection("experience")).sort(
|
const experience = (await getCollection("experience")).sort(
|
||||||
@@ -270,12 +271,11 @@ const projects = (await getCollection("projects")).sort(
|
|||||||
class="h-5 w-5"
|
class="h-5 w-5"
|
||||||
style="color: var(--color-accent);"
|
style="color: var(--color-accent);"
|
||||||
/>
|
/>
|
||||||
<span
|
<ObfuscatedEmail
|
||||||
|
address="contact@gregkilmer.com"
|
||||||
class="font-mono text-xs uppercase tracking-[0.18em]"
|
class="font-mono text-xs uppercase tracking-[0.18em]"
|
||||||
style="color: var(--color-accent);"
|
style="color: var(--color-accent);"
|
||||||
>
|
/>
|
||||||
Kilmer.GM@Gmail.com
|
|
||||||
</span>
|
|
||||||
</li>
|
</li>
|
||||||
<li class="flex items-center gap-2">
|
<li class="flex items-center gap-2">
|
||||||
<Icon
|
<Icon
|
||||||
|
|||||||
Reference in New Issue
Block a user