mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
a2f723ca2d
Phase 1 of the functional design plan. - Move the old site into legacy/, unchanged, for reference. It still builds from there until cutover. - Add packages/schema: the one definition of all 20 note types, with field titles and descriptions, note and vault checks, and JSON Schema export so later editing tools and the MCP server share it. - Add vault-kit: Obsidian templates generated from the schema, and the Site guide for 24.01_PersonalWebsite. - Add tools/migrate: turns the legacy JSON, collections, store data and page text into 545 Site folder notes and 84 images, and checks the result with the schema. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
219 lines
7.8 KiB
TypeScript
219 lines
7.8 KiB
TypeScript
/**
|
|
* /api/contact
|
|
* ---------------------------------------------------------------------------
|
|
* POST endpoint that receives the contact form. Pipeline:
|
|
* 1. Reject non-POST.
|
|
* 2. Validate JSON body with Zod (name, email, message; honeypot must be empty).
|
|
* 3. Rate-limit per IP (in-memory windowed counter).
|
|
* 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.
|
|
* 6. Always return JSON { ok } or { error }.
|
|
*
|
|
* Secrets come from env (see .env.example). Nothing secret ships to the client.
|
|
* This runs server-side only via the @astrojs/node adapter.
|
|
*/
|
|
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({
|
|
name: z.string().min(1).max(120),
|
|
email: z.string().email().max(200),
|
|
message: z.string().min(10).max(5000),
|
|
// Honeypot: must be absent/empty. Bots tend to fill every field.
|
|
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) ------------------------------------
|
|
// 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.
|
|
// 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) {
|
|
hits.set(ip, { count: 1, reset: now + rateWindowMin * 60_000 });
|
|
return false;
|
|
}
|
|
entry.count += 1;
|
|
return entry.count > rateLimit;
|
|
}
|
|
|
|
// --- Helpers ----------------------------------------------------------------
|
|
function json(body: unknown, status: number) {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
function clientIp(request: Request): string {
|
|
// Cosmos/Traefik typically forwards the real client IP here.
|
|
const fwd = request.headers.get('x-forwarded-for');
|
|
if (fwd) return fwd.split(',')[0]!.trim();
|
|
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 = turnstileSecretKey();
|
|
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 ----------------------------------------------------------
|
|
export const POST: APIRoute = async ({ request }) => {
|
|
if (request.method !== 'POST') {
|
|
return json({ error: 'Method not allowed.' }, 405);
|
|
}
|
|
|
|
// Parse + validate.
|
|
let payload: unknown;
|
|
try {
|
|
payload = await request.json();
|
|
} catch {
|
|
return json({ error: 'Invalid JSON body.' }, 400);
|
|
}
|
|
const parsed = schema.safeParse(payload);
|
|
if (!parsed.success) {
|
|
return json({ error: 'Please complete all fields correctly.', issues: parsed.error.issues }, 400);
|
|
}
|
|
const { name, email, message, turnstileToken } = parsed.data;
|
|
|
|
// Rate limit.
|
|
const ip = clientIp(request);
|
|
if (rateLimited(ip)) {
|
|
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.
|
|
try {
|
|
const delivered = await deliver({ name, email, message });
|
|
if (!delivered) {
|
|
// No SMTP or webhook configured. Tell the client clearly without leaking
|
|
// server internals, but return 503 so it is visibly a config gap.
|
|
return json({ error: 'Contact delivery is not configured yet. Please email me directly.' }, 503);
|
|
}
|
|
return json({ ok: true }, 200);
|
|
} catch (err) {
|
|
console.error('[contact] delivery failed:', err);
|
|
return json({ error: 'Could not send your message right now. Please try again later.' }, 502);
|
|
}
|
|
};
|
|
|
|
// --- Delivery: SMTP first, webhook fallback ---------------------------------
|
|
interface Message {
|
|
name: string;
|
|
email: string;
|
|
message: string;
|
|
}
|
|
|
|
async function deliver(msg: Message): Promise<boolean> {
|
|
const host = smtpHost();
|
|
const webhook = contactWebhookUrl();
|
|
|
|
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();
|
|
// Accept self-signed certificates when explicitly opted in. Intended for
|
|
// internal homelab hops where the SMTP relay (e.g. Stalwart on the same
|
|
// Docker network) presents a self-signed cert and the connection never
|
|
// traverses an untrusted network. Defaults to false so production-grade
|
|
// TLS verification is the default; the override is a deliberate choice.
|
|
const allowSelfSigned = env('SMTP_ALLOW_SELFSIGNED') === 'true';
|
|
const transport = nodemailer.createTransport({
|
|
host,
|
|
port,
|
|
secure: port === 465,
|
|
auth: user ? { user, pass: smtpPass() } : undefined,
|
|
...(allowSelfSigned
|
|
? { tls: { rejectUnauthorized: false } }
|
|
: {}),
|
|
});
|
|
const hostname = (() => {
|
|
try {
|
|
return new URL(process.env.SITE_URL ?? 'https://gregkilmer.com').hostname;
|
|
} catch {
|
|
return 'gregkilmer.com';
|
|
}
|
|
})();
|
|
await transport.sendMail({
|
|
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}`,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
if (webhook) {
|
|
const res = await fetch(webhook, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
text: `New contact from ${msg.name} (${msg.email}):\n\n${msg.message}`,
|
|
// Structured fields for webhook consumers (Discord/Slack/n8n).
|
|
name: msg.name,
|
|
email: msg.email,
|
|
message: msg.message,
|
|
}),
|
|
});
|
|
return res.ok;
|
|
}
|
|
|
|
return false;
|
|
}
|