Start the rebuild: note schema, vault kit and data migration

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
This commit is contained in:
Claude
2026-10-11 18:40:55 +00:00
parent 4ded4b277b
commit a2f723ca2d
324 changed files with 11538 additions and 7556 deletions
+218
View File
@@ -0,0 +1,218 @@
/**
* /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;
}
+243
View File
@@ -0,0 +1,243 @@
/**
* /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;
}