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:
+27
-14
@@ -15,6 +15,17 @@
|
||||
*/
|
||||
import type { APIRoute } from 'astro';
|
||||
import { z } from 'zod/v4';
|
||||
import {
|
||||
contactTo,
|
||||
contactWebhookUrl,
|
||||
env,
|
||||
smtpFrom,
|
||||
smtpHost,
|
||||
smtpPass,
|
||||
smtpPort,
|
||||
smtpUser,
|
||||
turnstileSecretKey,
|
||||
} from '@lib/env';
|
||||
|
||||
// --- Validation schema ------------------------------------------------------
|
||||
const schema = z.object({
|
||||
@@ -31,11 +42,13 @@ const schema = z.object({
|
||||
// --- In-memory rate limiter (per worker) ------------------------------------
|
||||
// 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.
|
||||
const rateWindowMin = Number(process.env.CONTACT_RATE_WINDOW_MIN ?? 15);
|
||||
const rateLimit = Number(process.env.CONTACT_RATE_LIMIT ?? 5);
|
||||
// Tunables are read per-request via the env helper so they resolve in both dev
|
||||
// (import.meta.env) and Docker (process.env).
|
||||
const hits = new Map<string, { count: number; reset: number }>();
|
||||
|
||||
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 entry = hits.get(ip);
|
||||
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
|
||||
// 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;
|
||||
const secret = turnstileSecretKey();
|
||||
if (!secret) return true; // Turnstile not configured -> skip.
|
||||
|
||||
if (!token) return false;
|
||||
@@ -146,19 +159,19 @@ interface Message {
|
||||
}
|
||||
|
||||
async function deliver(msg: Message): Promise<boolean> {
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const webhook = process.env.CONTACT_WEBHOOK_URL;
|
||||
const host = smtpHost();
|
||||
const webhook = contactWebhookUrl();
|
||||
|
||||
if (smtpHost) {
|
||||
if (host) {
|
||||
// Dynamic import keeps nodemailer out of cold-start when unused.
|
||||
const nodemailer = (await import('nodemailer')).default;
|
||||
const port = Number(smtpPort() ?? 587);
|
||||
const user = smtpUser();
|
||||
const transport = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: Number(process.env.SMTP_PORT ?? 587),
|
||||
secure: Number(process.env.SMTP_PORT ?? 587) === 465,
|
||||
auth: process.env.SMTP_USER
|
||||
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
|
||||
: undefined,
|
||||
host,
|
||||
port,
|
||||
secure: port === 465,
|
||||
auth: user ? { user, pass: smtpPass() } : undefined,
|
||||
});
|
||||
const hostname = (() => {
|
||||
try {
|
||||
@@ -168,8 +181,8 @@ async function deliver(msg: Message): Promise<boolean> {
|
||||
}
|
||||
})();
|
||||
await transport.sendMail({
|
||||
from: process.env.SMTP_FROM ?? `Greg's Site <no-reply@${hostname}>`,
|
||||
to: process.env.CONTACT_TO ?? 'greg@gregkilmer.com',
|
||||
from: smtpFrom() ?? `Greg's Site <no-reply@${hostname}>`,
|
||||
to: contactTo() ?? 'greg@gregkilmer.com',
|
||||
replyTo: `${msg.name} <${msg.email}>`,
|
||||
subject: `New message from ${msg.name} via gregkilmer.com`,
|
||||
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 ContactForm from "@components/professional/ContactForm.tsx";
|
||||
import ObfuscatedEmail from "@components/ObfuscatedEmail.astro";
|
||||
import { turnstileSiteKey as getTurnstileSiteKey } from "@lib/env";
|
||||
|
||||
// Email is rendered separately via <ObfuscatedEmail> so the plaintext address
|
||||
// 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).
|
||||
// IMPORTANT: read from process.env at request time, NOT import.meta.env.
|
||||
// Astro/Vite statically replaces import.meta.env.* at BUILD time, so the value
|
||||
// baked in at build would be "" when the key is supplied at runtime via the
|
||||
// container's env_file. process.env is read on each request, so the Docker
|
||||
// container picks up the key from .env at runtime. Undefined when Turnstile is
|
||||
// not configured, which makes ContactForm skip the widget entirely.
|
||||
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY;
|
||||
// Read via the env helper so it resolves in BOTH `astro dev` (Vite loads `.env`
|
||||
// into import.meta.env) AND the Docker runtime (env_file sets process.env).
|
||||
// See src/lib/env.ts for why neither source alone covers both runtimes.
|
||||
// Undefined when Turnstile is not configured, which makes ContactForm skip the
|
||||
// widget entirely.
|
||||
const turnstileSiteKey = getTurnstileSiteKey();
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
|
||||
Reference in New Issue
Block a user