mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
Contact form update
This commit is contained in:
@@ -47,7 +47,6 @@ 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).
|
// Holds the live Turnstile token between renders (cleared on reset/expiry).
|
||||||
const turnstileTokenRef = useRef<string>('');
|
const turnstileTokenRef = useRef<string>('');
|
||||||
@@ -56,48 +55,81 @@ export default function ContactForm({ turnstileSiteKey }: Props) {
|
|||||||
// Container div the widget mounts into.
|
// Container div the widget mounts into.
|
||||||
const turnstileContainerRef = useRef<HTMLDivElement | null>(null);
|
const turnstileContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// Load the Turnstile script once when a site key is configured.
|
// Load the Turnstile script once when a site key is configured, then render
|
||||||
|
// the widget once it's ready. Two effects coordinate via a script onload
|
||||||
|
// callback + a readiness poll, because the script loads async and
|
||||||
|
// window.turnstile isn't defined the moment the script tag is inserted.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!turnstileSiteKey) return;
|
if (!turnstileSiteKey) return;
|
||||||
if (document.getElementById('cf-turnstile-script')) return;
|
|
||||||
|
|
||||||
const script = document.createElement('script');
|
// If the script is already present (e.g. HMR / re-mount), just render.
|
||||||
script.id = 'cf-turnstile-script';
|
if (window.turnstile) {
|
||||||
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
|
renderWidget();
|
||||||
script.async = true;
|
return;
|
||||||
script.defer = true;
|
}
|
||||||
document.head.appendChild(script);
|
|
||||||
|
// Avoid injecting a second script tag on re-mount.
|
||||||
|
let script = document.getElementById(
|
||||||
|
'cf-turnstile-script',
|
||||||
|
) as HTMLScriptElement | null;
|
||||||
|
if (!script) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the script finishes loading, render the widget. onload fires only
|
||||||
|
// once even if multiple components share the same script tag.
|
||||||
|
script.addEventListener('load', renderWidget);
|
||||||
|
return () => {
|
||||||
|
script?.removeEventListener('load', renderWidget);
|
||||||
|
};
|
||||||
}, [turnstileSiteKey]);
|
}, [turnstileSiteKey]);
|
||||||
|
|
||||||
// Once the script + container are present, render the widget.
|
function renderWidget() {
|
||||||
useEffect(() => {
|
|
||||||
if (!turnstileSiteKey) return;
|
if (!turnstileSiteKey) return;
|
||||||
if (!window.turnstile || !turnstileContainerRef.current) return;
|
if (!window.turnstile || !turnstileContainerRef.current) return;
|
||||||
|
// Avoid double-rendering if this fires twice (StrictMode / onload + poll).
|
||||||
// Avoid double-rendering if the effect fires twice in StrictMode/dev.
|
|
||||||
if (turnstileWidgetIdRef.current) return;
|
if (turnstileWidgetIdRef.current) return;
|
||||||
|
|
||||||
turnstileWidgetIdRef.current = window.turnstile.render(
|
turnstileWidgetIdRef.current = window.turnstile.render(
|
||||||
turnstileContainerRef.current,
|
turnstileContainerRef.current,
|
||||||
{
|
{
|
||||||
sitekey: turnstileSiteKey,
|
sitekey: turnstileSiteKey,
|
||||||
// Called by Turnstile once it has a token (success) or on expiry.
|
// Called by Turnstile once it has a token. For invisible/Managed mode
|
||||||
|
// this fires automatically once the challenge resolves.
|
||||||
callback: (token: string) => {
|
callback: (token: string) => {
|
||||||
turnstileTokenRef.current = token;
|
turnstileTokenRef.current = token;
|
||||||
setTurnstileReady(true);
|
|
||||||
},
|
},
|
||||||
|
// On expiry/error, clear the token so the server rejects the stale
|
||||||
|
// submission — but DON'T re-disable the button, because an invisible
|
||||||
|
// widget gives the user nothing to click to re-solve it. Instead we
|
||||||
|
// reset the widget, which re-runs the challenge and fires callback
|
||||||
|
// again with a fresh token.
|
||||||
|
// On token expiry: clear it. The next submit will detect the empty
|
||||||
|
// token and reset the widget for a fresh challenge. We don't auto-reset
|
||||||
|
// here to keep behavior simple and avoid any retry churn.
|
||||||
'expired-callback': () => {
|
'expired-callback': () => {
|
||||||
turnstileTokenRef.current = '';
|
turnstileTokenRef.current = '';
|
||||||
setTurnstileReady(false);
|
|
||||||
},
|
},
|
||||||
|
// On widget error: clear the token but DO NOT call turnstile.reset().
|
||||||
|
// Resetting from inside error-callback causes Turnstile to immediately
|
||||||
|
// retry, hit the same error (e.g. 110200 domain-not-authorized), and
|
||||||
|
// fire error-callback again — an infinite loop that manifests as the
|
||||||
|
// widget rapidly flashing. Instead we leave the widget in its errored
|
||||||
|
// state; the next form submission will reset it for a fresh attempt.
|
||||||
|
// The error surfaces as the token being empty, which onSubmit reports.
|
||||||
'error-callback': () => {
|
'error-callback': () => {
|
||||||
turnstileTokenRef.current = '';
|
turnstileTokenRef.current = '';
|
||||||
setTurnstileReady(false);
|
|
||||||
},
|
},
|
||||||
theme: 'dark',
|
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 = {};
|
||||||
@@ -141,21 +173,22 @@ export default function ContactForm({ turnstileSiteKey }: Props) {
|
|||||||
});
|
});
|
||||||
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.
|
// Reset the Turnstile widget so a fresh challenge runs. The widget's
|
||||||
|
// callback will populate the token ref with a new value when it
|
||||||
|
// resolves; the button is never disabled on Turnstile state, so there's
|
||||||
|
// no UI flag to manage here.
|
||||||
if (turnstileSiteKey && turnstileWidgetIdRef.current) {
|
if (turnstileSiteKey && turnstileWidgetIdRef.current) {
|
||||||
window.turnstile?.reset(turnstileWidgetIdRef.current);
|
window.turnstile?.reset(turnstileWidgetIdRef.current);
|
||||||
turnstileTokenRef.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.
|
// Fresh challenge for the next submission.
|
||||||
if (turnstileSiteKey && turnstileWidgetIdRef.current) {
|
if (turnstileSiteKey && turnstileWidgetIdRef.current) {
|
||||||
window.turnstile?.reset(turnstileWidgetIdRef.current);
|
window.turnstile?.reset(turnstileWidgetIdRef.current);
|
||||||
turnstileTokenRef.current = '';
|
turnstileTokenRef.current = '';
|
||||||
setTurnstileReady(false);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
@@ -276,7 +309,7 @@ export default function ContactForm({ turnstileSiteKey }: Props) {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={status === 'submitting' || (Boolean(turnstileSiteKey) && !turnstileReady)}
|
disabled={status === 'submitting'}
|
||||||
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'}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* env.ts
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Runtime env-var reader that works in BOTH local dev (`astro dev`, where the
|
||||||
|
* `.env` file is NOT auto-loaded into `process.env`) AND the Docker production
|
||||||
|
* runtime (where docker-compose `env_file` sets real `process.env`).
|
||||||
|
*
|
||||||
|
* Background: Astro/Vite loads `.env` into `import.meta.env`, NOT `process.env`
|
||||||
|
* (https://github.com/withastro/roadmap/issues/853). We deliberately AVOID
|
||||||
|
* `import.meta.env` for these reads because Vite statically replaces it at
|
||||||
|
* build time — and for SECRET values (SMTP password, Turnstile secret key)
|
||||||
|
* that means the secret gets baked into the built JS bundle in plaintext,
|
||||||
|
* which is a security leak and defeats the point of env vars.
|
||||||
|
*
|
||||||
|
* Solution: at module load (once), pull values from the `.env` file via Vite's
|
||||||
|
* `loadEnv` and merge any MISSING keys into `process.env`. Existing real env
|
||||||
|
* vars (e.g. Docker's) always take precedence. All reads then go through
|
||||||
|
* `process.env`, which is runtime-only and never baked into the bundle.
|
||||||
|
*
|
||||||
|
* `loadEnv` is imported from `vite`, which is already a transitive dependency
|
||||||
|
* via Astro — no new dependency required.
|
||||||
|
*/
|
||||||
|
import { loadEnv } from 'vite';
|
||||||
|
|
||||||
|
// One-time: populate process.env from the .env file for keys not already set
|
||||||
|
// by the real environment (Docker env_file / shell exports). This makes local
|
||||||
|
// dev work without any extra setup, and is a no-op in Docker where process.env
|
||||||
|
// is already fully populated.
|
||||||
|
//
|
||||||
|
// NOTE: do NOT reference `import.meta.env` anywhere in this module. Vite
|
||||||
|
// statically replaces any `import.meta.env` usage at build time, and for a
|
||||||
|
// bare reference it inlines the ENTIRE env object (including secrets like
|
||||||
|
// SMTP_PASS / TURNSTILE_SECRET_KEY) as a literal in the bundle. Use
|
||||||
|
// process.env.NODE_ENV to determine mode instead.
|
||||||
|
try {
|
||||||
|
const mode = process.env.NODE_ENV === 'production' ? 'production' : 'development';
|
||||||
|
const fromFile = loadEnv(
|
||||||
|
mode,
|
||||||
|
process.cwd(),
|
||||||
|
// Empty prefix = load ALL vars (not just PUBLIC_*). They land only in the
|
||||||
|
// returned object, not the client bundle, because we copy them into
|
||||||
|
// process.env here rather than referencing import.meta.env elsewhere.
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
for (const [key, value] of Object.entries(fromFile)) {
|
||||||
|
if (process.env[key] === undefined && value !== '') {
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// loadEnv throws if it can't find a project root / .env; safe to ignore in
|
||||||
|
// environments where there's no .env (e.g. some serverless runtimes). Real
|
||||||
|
// env vars set via the platform still work via process.env directly.
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a single env var from process.env (runtime only; never baked into bundle). */
|
||||||
|
export function env(key: string): string | undefined {
|
||||||
|
return process.env[key] || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public Cloudflare Turnstile site key (safe to ship to the client). */
|
||||||
|
export const turnstileSiteKey = (): string | undefined => env('TURNSTILE_SITE_KEY');
|
||||||
|
|
||||||
|
/** Cloudflare Turnstile secret key (server-only). */
|
||||||
|
export const turnstileSecretKey = (): string | undefined => env('TURNSTILE_SECRET_KEY');
|
||||||
|
|
||||||
|
/** SMTP relay host. When unset, the contact form returns 503. */
|
||||||
|
export const smtpHost = (): string | undefined => env('SMTP_HOST');
|
||||||
|
|
||||||
|
/** SMTP relay port (defaults to 587 at the call site). */
|
||||||
|
export const smtpPort = (): string | undefined => env('SMTP_PORT');
|
||||||
|
|
||||||
|
/** SMTP auth username, if the relay requires auth. */
|
||||||
|
export const smtpUser = (): string | undefined => env('SMTP_USER');
|
||||||
|
|
||||||
|
/** SMTP auth password, if the relay requires auth. */
|
||||||
|
export const smtpPass = (): string | undefined => env('SMTP_PASS');
|
||||||
|
|
||||||
|
/** Inbox that receives contact form submissions. */
|
||||||
|
export const contactTo = (): string | undefined => env('CONTACT_TO');
|
||||||
|
|
||||||
|
/** From address for outgoing mail. */
|
||||||
|
export const smtpFrom = (): string | undefined => env('SMTP_FROM');
|
||||||
|
|
||||||
|
/** Optional webhook URL for delivery fallback (Discord/Slack/n8n). */
|
||||||
|
export const contactWebhookUrl = (): string | undefined => env('CONTACT_WEBHOOK_URL');
|
||||||
+27
-14
@@ -15,6 +15,17 @@
|
|||||||
*/
|
*/
|
||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
|
import {
|
||||||
|
contactTo,
|
||||||
|
contactWebhookUrl,
|
||||||
|
env,
|
||||||
|
smtpFrom,
|
||||||
|
smtpHost,
|
||||||
|
smtpPass,
|
||||||
|
smtpPort,
|
||||||
|
smtpUser,
|
||||||
|
turnstileSecretKey,
|
||||||
|
} from '@lib/env';
|
||||||
|
|
||||||
// --- Validation schema ------------------------------------------------------
|
// --- Validation schema ------------------------------------------------------
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
@@ -31,11 +42,13 @@ const schema = z.object({
|
|||||||
// --- In-memory rate limiter (per worker) ------------------------------------
|
// --- In-memory rate limiter (per worker) ------------------------------------
|
||||||
// Good enough for a personal site behind a reverse proxy. For multi-instance,
|
// Good enough for a personal site behind a reverse proxy. For multi-instance,
|
||||||
// swap this for Redis/Upstash. Window = N submissions per IP per X minutes.
|
// swap this for Redis/Upstash. Window = N submissions per IP per X minutes.
|
||||||
const rateWindowMin = Number(process.env.CONTACT_RATE_WINDOW_MIN ?? 15);
|
// Tunables are read per-request via the env helper so they resolve in both dev
|
||||||
const rateLimit = Number(process.env.CONTACT_RATE_LIMIT ?? 5);
|
// (import.meta.env) and Docker (process.env).
|
||||||
const hits = new Map<string, { count: number; reset: number }>();
|
const hits = new Map<string, { count: number; reset: number }>();
|
||||||
|
|
||||||
function rateLimited(ip: string): boolean {
|
function rateLimited(ip: string): boolean {
|
||||||
|
const rateWindowMin = Number(env('CONTACT_RATE_WINDOW_MIN') ?? 15);
|
||||||
|
const rateLimit = Number(env('CONTACT_RATE_LIMIT') ?? 5);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const entry = hits.get(ip);
|
const entry = hits.get(ip);
|
||||||
if (!entry || now > entry.reset) {
|
if (!entry || now > entry.reset) {
|
||||||
@@ -66,7 +79,7 @@ function clientIp(request: Request): string {
|
|||||||
// the token from the client is POSTed to Cloudflare's siteverify endpoint
|
// 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.
|
// along with the visitor's IP for fraud scoring. Only valid tokens proceed.
|
||||||
async function verifyTurnstile(token: string | undefined, ip: string): Promise<boolean> {
|
async function verifyTurnstile(token: string | undefined, ip: string): Promise<boolean> {
|
||||||
const secret = process.env.TURNSTILE_SECRET_KEY;
|
const secret = turnstileSecretKey();
|
||||||
if (!secret) return true; // Turnstile not configured -> skip.
|
if (!secret) return true; // Turnstile not configured -> skip.
|
||||||
|
|
||||||
if (!token) return false;
|
if (!token) return false;
|
||||||
@@ -146,19 +159,19 @@ interface Message {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deliver(msg: Message): Promise<boolean> {
|
async function deliver(msg: Message): Promise<boolean> {
|
||||||
const smtpHost = process.env.SMTP_HOST;
|
const host = smtpHost();
|
||||||
const webhook = process.env.CONTACT_WEBHOOK_URL;
|
const webhook = contactWebhookUrl();
|
||||||
|
|
||||||
if (smtpHost) {
|
if (host) {
|
||||||
// Dynamic import keeps nodemailer out of cold-start when unused.
|
// Dynamic import keeps nodemailer out of cold-start when unused.
|
||||||
const nodemailer = (await import('nodemailer')).default;
|
const nodemailer = (await import('nodemailer')).default;
|
||||||
|
const port = Number(smtpPort() ?? 587);
|
||||||
|
const user = smtpUser();
|
||||||
const transport = nodemailer.createTransport({
|
const transport = nodemailer.createTransport({
|
||||||
host: smtpHost,
|
host,
|
||||||
port: Number(process.env.SMTP_PORT ?? 587),
|
port,
|
||||||
secure: Number(process.env.SMTP_PORT ?? 587) === 465,
|
secure: port === 465,
|
||||||
auth: process.env.SMTP_USER
|
auth: user ? { user, pass: smtpPass() } : undefined,
|
||||||
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
|
|
||||||
: undefined,
|
|
||||||
});
|
});
|
||||||
const hostname = (() => {
|
const hostname = (() => {
|
||||||
try {
|
try {
|
||||||
@@ -168,8 +181,8 @@ async function deliver(msg: Message): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
await transport.sendMail({
|
await transport.sendMail({
|
||||||
from: process.env.SMTP_FROM ?? `Greg's Site <no-reply@${hostname}>`,
|
from: smtpFrom() ?? `Greg's Site <no-reply@${hostname}>`,
|
||||||
to: process.env.CONTACT_TO ?? 'greg@gregkilmer.com',
|
to: contactTo() ?? 'greg@gregkilmer.com',
|
||||||
replyTo: `${msg.name} <${msg.email}>`,
|
replyTo: `${msg.name} <${msg.email}>`,
|
||||||
subject: `New message from ${msg.name} via gregkilmer.com`,
|
subject: `New message from ${msg.name} via gregkilmer.com`,
|
||||||
text: `Name: ${msg.name}\nEmail: ${msg.email}\n\n${msg.message}`,
|
text: `Name: ${msg.name}\nEmail: ${msg.email}\n\n${msg.message}`,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ 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";
|
import ObfuscatedEmail from "@components/ObfuscatedEmail.astro";
|
||||||
|
import { turnstileSiteKey as getTurnstileSiteKey } from "@lib/env";
|
||||||
|
|
||||||
// Email is rendered separately via <ObfuscatedEmail> so the plaintext address
|
// Email is rendered separately via <ObfuscatedEmail> so the plaintext address
|
||||||
// never appears in the HTML source. The other entries are plain links.
|
// never appears in the HTML source. The other entries are plain links.
|
||||||
@@ -57,13 +58,12 @@ const social = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Turnstile site key. Public per Cloudflare (safe to ship to the client).
|
// Turnstile site key. Public per Cloudflare (safe to ship to the client).
|
||||||
// IMPORTANT: read from process.env at request time, NOT import.meta.env.
|
// Read via the env helper so it resolves in BOTH `astro dev` (Vite loads `.env`
|
||||||
// Astro/Vite statically replaces import.meta.env.* at BUILD time, so the value
|
// into import.meta.env) AND the Docker runtime (env_file sets process.env).
|
||||||
// baked in at build would be "" when the key is supplied at runtime via the
|
// See src/lib/env.ts for why neither source alone covers both runtimes.
|
||||||
// container's env_file. process.env is read on each request, so the Docker
|
// Undefined when Turnstile is not configured, which makes ContactForm skip the
|
||||||
// container picks up the key from .env at runtime. Undefined when Turnstile is
|
// widget entirely.
|
||||||
// not configured, which makes ContactForm skip the widget entirely.
|
const turnstileSiteKey = getTurnstileSiteKey();
|
||||||
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY;
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout
|
<BaseLayout
|
||||||
|
|||||||
Reference in New Issue
Block a user