import type { APIEvent } from "@solidjs/start/server"; import { z } from "zod"; import sendEmail from "~/server/email"; import { CONTACT_RECIPIENT_EMAIL } from "~/lib/contact-config"; import { takeBugReportToken } from "~/server/bug-report-rate-limit"; import { resolveTrustedClientIp } from "~/server/trusted-client-ip"; import { json, error } from "./_lib"; /** * POST /api/the-nook/report * Body: { appVersion, title, description, contact?, machine, displays } * * Emails the developer a Nook bug report in plain prose — each field on * its own line. The Mac client collects the machine facts itself (model, * CPU, memory, every display); styling is irrelevant, information is the * product here. */ const displaySchema = z.object({ name: z.string().max(200).default(""), boundsPx: z.string().max(100).default(""), scale: z.string().max(20).default(""), hz: z.string().max(20).default(""), builtin: z.boolean().default(false) }); const reportSchema = z.object({ title: z.string().trim().min(1).max(200), description: z.string().trim().max(20_000).default(""), contact: z.string().trim().max(254).default(""), appVersion: z.string().trim().max(100).default(""), machine: z .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) .default({}), displays: z.array(displaySchema).max(8).default([]) }); type Report = z.infer; const contactRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; // One escaping contract for every interpolated string (many call sites). function escapeHtml(str: string): string { return str .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function renderBody(report: Report): string { const machineLines = Object.entries(report.machine).map( ([key, value]) => `${escapeHtml(key)}: ${escapeHtml(String(value))}` ); const displayLines = report.displays.map((display) => [ `name: ${escapeHtml(display.name)}`, `boundsPx: ${escapeHtml(display.boundsPx)}`, `scale: ${escapeHtml(display.scale)}`, `hz: ${escapeHtml(display.hz)}`, `builtin: ${escapeHtml(String(display.builtin))}` ].join(" · ") ); return [ `

The Nook bug report

`, `

“${escapeHtml(report.title)}”

`, report.description ? `

What happened

${escapeHtml(report.description).replace(/\r\n|\n|\r/g, "
")}
` : "", report.contact && contactRe.test(report.contact) ? `

Reply-to: ${escapeHtml(report.contact)}

` : "", `

Snapshot

`, report.appVersion ? `

${escapeHtml(report.appVersion)}

` : "", machineLines.length > 0 ? `

${machineLines.join("
")}

` : "", displayLines.length > 0 ? `

Displays

${displayLines.map((line) => `
${line}
`).join("")}
` : "" ] .filter((chunk) => chunk !== "") .join("\n"); } export async function POST(event: APIEvent): Promise { let raw: unknown; try { raw = await event.request.json(); } catch { return error("Invalid JSON", 400); } const parsed = reportSchema.safeParse(raw); if (!parsed.success) { return error("Invalid report", 400); } const report = parsed.data; if (report.contact && !contactRe.test(report.contact)) { return error("Invalid reply-to email", 400); } const fingerprint = ( event.request.headers.get("x-nook-fingerprint") ?? "" ) .trim() .toLowerCase(); // FRE-371: the IP fallback must be the spoof-resistant read — a rotating // leftmost x-forwarded-for used to mint unlimited report quota. const rateKey = fingerprint || resolveTrustedClientIp(event); const token = takeBugReportToken(rateKey); if (!token.allowed) { return new Response(JSON.stringify({ error: "Too many reports" }), { status: 429, headers: { "Content-Type": "application/json", "Retry-After": String(token.retryAfterSec ?? 3600) } }); } const subject = `The Nook bug report: ${report.title.slice(0, 100)}`; const result = await sendEmail( CONTACT_RECIPIENT_EMAIL, subject, renderBody(report) ); if (!result.success) { return error("Failed to send report", 500); } return json({ success: true }); }