mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
Initial Commit
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* /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. Deliver: SMTP via nodemailer if configured, else POST to a webhook if
|
||||
* configured, else return 503 so delivery can be wired later.
|
||||
* 5. 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';
|
||||
|
||||
// --- 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('')),
|
||||
});
|
||||
|
||||
// --- 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);
|
||||
const hits = new Map<string, { count: number; reset: number }>();
|
||||
|
||||
function rateLimited(ip: string): boolean {
|
||||
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';
|
||||
}
|
||||
|
||||
// --- 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 } = parsed.data;
|
||||
|
||||
// Rate limit.
|
||||
const ip = clientIp(request);
|
||||
if (rateLimited(ip)) {
|
||||
return json({ error: 'Too many submissions. Please try again later.' }, 429);
|
||||
}
|
||||
|
||||
// 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 smtpHost = process.env.SMTP_HOST;
|
||||
const webhook = process.env.CONTACT_WEBHOOK_URL;
|
||||
|
||||
if (smtpHost) {
|
||||
// Dynamic import keeps nodemailer out of cold-start when unused.
|
||||
const nodemailer = (await import('nodemailer')).default;
|
||||
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,
|
||||
});
|
||||
const hostname = (() => {
|
||||
try {
|
||||
return new URL(process.env.SITE_URL ?? 'https://patiun.com').hostname;
|
||||
} catch {
|
||||
return 'patiun.com';
|
||||
}
|
||||
})();
|
||||
await transport.sendMail({
|
||||
from: process.env.SMTP_FROM ?? `Greg's Site <no-reply@${hostname}>`,
|
||||
to: process.env.CONTACT_TO ?? 'greg@patiun.com',
|
||||
replyTo: `${msg.name} <${msg.email}>`,
|
||||
subject: `New message from ${msg.name} via patiun.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;
|
||||
}
|
||||
Reference in New Issue
Block a user