mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
244 lines
8.1 KiB
TypeScript
244 lines
8.1 KiB
TypeScript
/**
|
|
* /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<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, 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<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}`,
|
|
`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 <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,
|
|
color: msg.color || undefined,
|
|
quantity: msg.quantity,
|
|
details: msg.details,
|
|
notes: msg.notes,
|
|
}),
|
|
});
|
|
return res.ok;
|
|
}
|
|
|
|
return false;
|
|
}
|