Store base

This commit is contained in:
Greg Kilmer
2026-08-06 12:13:48 -04:00
parent 51a7b9fee0
commit f4a49fe9bc
28 changed files with 2471 additions and 2 deletions
+238
View File
@@ -0,0 +1,238 @@
/**
* /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'),
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<string, string> = {
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<string, { count: number; reset: number }>();
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<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) {
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, 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, 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;
quantity: string;
details: string;
notes: string;
}
async function deliver(msg: OrderMessage): Promise<boolean> {
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}`,
`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 <no-reply@${hostname}>`,
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,
quantity: msg.quantity,
details: msg.details,
notes: msg.notes,
}),
});
return res.ok;
}
return false;
}