/** * /api/order * --------------------------------------------------------------------------- * POST endpoint for store order requests. Pipeline mirrors /api/contact: * 1. Reject non-POST. * 2. HARD GATE: read store-availability.json; if ordersOpen is false, return * 403 immediately. This enforces the lock server-side so it can't be * bypassed by POSTing directly while the client form is hidden. * 3. Validate JSON body with Zod. * 4. Rate-limit per IP (in-memory windowed counter). * 5. Verify Cloudflare Turnstile token (if TURNSTILE_SECRET_KEY is set). * 6. Deliver via SMTP (nodemailer) if configured, else webhook, else 503. * * This collects an ORDER REQUEST only — no payment is processed. The payload is * emailed to the configured inbox (ORDER_TO, falling back to CONTACT_TO). */ import type { APIRoute } from 'astro'; import { z } from 'zod/v4'; import { contactWebhookUrl, env, orderTo, smtpFrom, smtpHost, smtpPass, smtpPort, smtpUser, turnstileSecretKey, } from '@lib/env'; import { storeAvailability } from '@data/store-availability'; // --- Validation schema ------------------------------------------------------ const orderTypeEnum = z.enum(['cards', 'deckBoxes', 'both']); const schema = z.object({ name: z.string().min(1).max(120), email: z.string().email().max(200), orderType: orderTypeEnum.default('cards'), // Filament color for printed items. Optional (only for deckBoxes/both). color: z.string().max(120).optional().or(z.literal('')), quantity: z.string().min(1).max(200), details: z.string().min(10).max(20000), notes: z.string().max(5000).optional().or(z.literal('')), // Policy acknowledgment checkbox must be ticked. agree: z.literal(true), // Honeypot: must be empty. company: z.string().max(0).optional().or(z.literal('')), // Turnstile token (required when TURNSTILE_SECRET_KEY is set). turnstileToken: z.string().optional(), }); // Human-readable label for the order type, used in the email subject/body. const orderTypeLabel: Record = { cards: 'Proxy MTG cards', deckBoxes: '3D-printed deck box / counters', both: 'Cards + 3D prints', }; // --- In-memory rate limiter (per worker) ------------------------------------ // Separate counter namespace from /api/contact so order submits don't share // the contact budget. Tunables read via env helper for dev + Docker parity. const hits = new Map(); function rateLimited(ip: string): boolean { const rateWindowMin = Number(env('ORDER_RATE_WINDOW_MIN') ?? env('CONTACT_RATE_WINDOW_MIN') ?? 15); const rateLimit = Number(env('ORDER_RATE_LIMIT') ?? 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 { 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 (same logic as /api/contact) -------------------- async function verifyTurnstile(token: string | undefined, ip: string): Promise { 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) { console.error('[order] 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); } // HARD GATE: orders closed -> reject before any other work. Re-reading the // JSON module here reflects whatever Greg committed to the file at deploy. if (!storeAvailability.ordersOpen) { return json({ error: 'Orders are currently closed. Please check back soon.' }, 403); } // 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, including the policy acknowledgment.', issues: parsed.error.issues }, 400); } const { name, email, orderType, color, quantity, details, notes, turnstileToken } = parsed.data; // Rate limit. const ip = clientIp(request); if (rateLimited(ip)) { return json({ error: 'Too many submissions. Please try again later.' }, 429); } // Turnstile. 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, orderType, color: color ?? '', quantity, details, notes: notes ?? '' }); if (!delivered) { return json({ error: 'Order delivery is not configured yet. Please email me directly.' }, 503); } return json({ ok: true }, 200); } catch (err) { console.error('[order] delivery failed:', err); return json({ error: 'Could not send your order right now. Please try again later.' }, 502); } }; // --- Delivery: SMTP first, webhook fallback --------------------------------- interface OrderMessage { name: string; email: string; orderType: string; color: string; quantity: string; details: string; notes: string; } async function deliver(msg: OrderMessage): Promise { const host = smtpHost(); const webhook = contactWebhookUrl(); const typeLabel = orderTypeLabel[msg.orderType] ?? msg.orderType; // Plain-text body for the email. const text = [ `New store order request`, ``, `Name: ${msg.name}`, `Email: ${msg.email}`, `Order type: ${typeLabel}`, `Color: ${msg.color || '(not specified)'}`, `Approx quantity: ${msg.quantity}`, ``, `Details:`, msg.details, ``, msg.notes ? `Notes:\n${msg.notes}` : '', ].join('\n'); if (host) { const nodemailer = (await import('nodemailer')).default; const port = Number(smtpPort() ?? 587); const user = smtpUser(); 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 Store `, to: orderTo() ?? 'greg@gregkilmer.com', replyTo: `${msg.name} <${msg.email}>`, subject: `New store order — ${typeLabel} (${msg.name})`, text, }); return true; } if (webhook) { const res = await fetch(webhook, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: `New store order from ${msg.name} (${msg.email}) — ${typeLabel}`, name: msg.name, email: msg.email, orderType: typeLabel, color: msg.color || undefined, quantity: msg.quantity, details: msg.details, notes: msg.notes, }), }); return res.ok; } return false; }