diff --git a/src/routes/api/the-nook/report.test.ts b/src/routes/api/the-nook/report.test.ts new file mode 100644 index 0000000..04bad4f --- /dev/null +++ b/src/routes/api/the-nook/report.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, mock, beforeEach } from "bun:test"; + +// The route's core contracts: +// 1. A valid report is emailed to the owner with the machine snapshot +// and description, HTML-escaped. +// 2. Invalid payloads never reach the mailer. +// 3. The rate limiter halts floods per fingerprint (falling back to IP). +// sendEmail and the limiter are mocked so the test sends no real mail. + +const sentEmails: { to: string; subject: string; html: string }[] = []; +const tokenAnswers: { allowed: boolean; retryAfterSec?: number }[] = []; + +mock.module("~/server/email", () => ({ + default: async (to: string, subject: string, html: string) => { + sentEmails.push({ to, subject, html }); + return { success: true, messageId: "test" }; + } +})); + +mock.module("~/server/bug-report-rate-limit", () => ({ + takeBugReportToken: () => tokenAnswers.shift() ?? { allowed: true } +})); + +const { POST } = await import("./report"); + +/** A minimal APIEvent double: JSON body plus optional headers. */ +function request(body: unknown, headers: Record = {}) { + return { + request: { + json: async () => body, + headers: new Headers(headers) + } + }; +} + +const validPayload = { + appVersion: "The Nook 0.2.0 (12)", + title: "Island flickers", + description: "It happened & stayed", + contact: "", + machine: { + macOS: "macOS 15.5", + model: "Mac15,6", + cpu: "Apple M3 Pro", + memoryGB: 36, + freeDiskGB: 100, + locale: "en_US" + }, + displays: [ + { + name: "Built-in", + boundsPx: "3456×2234 pt", + scale: "2.0x", + hz: "120", + builtin: true + }, + { + name: "DELL U2723QE", + boundsPx: "3008×1692 pt", + scale: "1.0x", + hz: "120", + builtin: false + } + ] +}; + +describe("POST /api/the-nook/report", () => { + beforeEach(() => { + sentEmails.length = 0; + tokenAnswers.length = 0; + }); + + it("emails the owner a report containing the snapshot, escaped", async () => { + const res = await POST(request(validPayload)); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ success: true }); + + expect(sentEmails).toHaveLength(1); + const email = sentEmails[0]!; + expect(email.subject).toBe("The Nook bug report: Island flickers"); + expect(email.to).toBe("michael@freno.me"); + for (const fact of ["Mac15,6", "Apple M3 Pro", "macOS 15.5", "36"]) { + expect(email.html).toContain(fact); + } + expect(email.html).toContain("Built-in"); + expect(email.html).toContain("3456×2234 pt"); + expect(email.html).toContain("DELL U2723QE"); + expect(email.html).toContain("3008×1692 pt"); + expect(email.html).toContain("builtin: false"); + expect(email.html).toContain("It happened <twice> & stayed"); + }); + + it("rejects a missing, empty, or oversized title", async () => { + const empty = await POST(request({ ...validPayload, title: " " })); + expect(empty.status).toBe(400); + const missing = await POST( + request({ ...validPayload, title: undefined }) + ); + expect(missing.status).toBe(400); + const tooLong = await POST( + request({ ...validPayload, title: "x".repeat(201) }) + ); + expect(tooLong.status).toBe(400); + expect(sentEmails).toHaveLength(0); + }); + + it("rejects a malformed reply-to contact", async () => { + const res = await POST( + request({ ...validPayload, contact: "not-an-email" }) + ); + expect(res.status).toBe(400); + expect(sentEmails).toHaveLength(0); + }); + + it("rejects more than eight displays", async () => { + const displays = Array.from({ length: 9 }, () => validPayload.displays[0]!); + const res = await POST(request({ ...validPayload, displays })); + expect(res.status).toBe(400); + expect(sentEmails).toHaveLength(0); + }); + + it("returns 429 with Retry-After when the limiter denies the token", async () => { + tokenAnswers.push({ allowed: false, retryAfterSec: 3600 }); + const res = await POST(request(validPayload)); + expect(res.status).toBe(429); + expect(res.headers.get("Retry-After")).toBe("3600"); + expect(sentEmails).toHaveLength(0); + }); + + it("still succeeds when no fingerprint header exists", async () => { + const res = await POST( + request(validPayload, { "x-forwarded-for": "203.0.113.9, 10.0.0.1" }) + ); + expect(res.status).toBe(200); + expect(sentEmails).toHaveLength(1); + }); +}); diff --git a/src/routes/api/the-nook/report.ts b/src/routes/api/the-nook/report.ts new file mode 100644 index 0000000..bbf7b7f --- /dev/null +++ b/src/routes/api/the-nook/report.ts @@ -0,0 +1,131 @@ +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 { 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(); + const forwarded = event.request.headers.get("x-forwarded-for"); + const rateKey = + fingerprint || + (forwarded ? forwarded.split(",")[0]!.trim() : "unknown"); + 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 }); +} diff --git a/src/routes/nook/checkout.tsx b/src/routes/nook/checkout.tsx index 7aa5c39..3067369 100644 --- a/src/routes/nook/checkout.tsx +++ b/src/routes/nook/checkout.tsx @@ -49,7 +49,10 @@ export default function NookCheckout() { headers: { "content-type": "application/json" }, body: JSON.stringify({ turnstileToken: turnstileToken() }) }); - const data = (await res.json()) as { checkoutUrl?: string; error?: string }; + const data = (await res.json()) as { + checkoutUrl?: string; + error?: string; + }; if (!res.ok || !data.checkoutUrl) { setError(data.error ?? "Unable to start checkout. Please try again."); return; @@ -74,21 +77,20 @@ export default function NookCheckout() {
- $10 one-time + $10 + one-time
$15

- + Beta pricing — 33% off

    -
  • • Activate on up to 3 of your own Macs
  • +
  • • Activate on up to 3 devices
  • • No subscription, ever
  • • Key delivered by email + on this page
@@ -106,11 +108,10 @@ export default function NookCheckout() { Pay $10 - {error() && ( -

{error()}

- )} + {error() &&

{error()}

}

- Billed once through Stripe. License delivered immediately after payment. + Billed once through Stripe. License delivered immediately after + payment.

diff --git a/src/server/bug-report-rate-limit.ts b/src/server/bug-report-rate-limit.ts new file mode 100644 index 0000000..b0e5f5b --- /dev/null +++ b/src/server/bug-report-rate-limit.ts @@ -0,0 +1,57 @@ +/** + * Bug-report rate limiting. + * + * Unauthenticated endpoint → in-memory limiter. Vercel lambdas are + * per-instance, so this is best-effort abuse resistance, not a hard + * guarantee; the goal is "one scripted spam loop per IP/fingerprint + * doesn't mail-bomb the owner," which it achieves everywhere but + * under a distributed attacker. + * + * NB: `report.ts` (and only it) mutates the map. Tests reset it. + */ + +interface RateWindow { + count: number; + expires: number; +} + +const buckets = new Map(); + +/** Keep the map tiny even under foreign-key pressure. */ +const MAX_BUCKETS = 10_000; + +export const BUG_REPORT_WINDOW_MS = 60 * 60 * 1000; +export const BUG_REPORT_LIMIT = 10; + +/** + * Increments the client's counter and reports whether it may submit. + * `expiresIn`/`now` are injectable for tests. + */ +export function takeBugReportToken( + key: string, + expiresIn: number = BUG_REPORT_WINDOW_MS, + now: number = Date.now() +): { allowed: boolean; retryAfterSec?: number } { + const existing = buckets.get(key); + if (existing && existing.expires > now) { + if (existing.count >= BUG_REPORT_LIMIT) { + return { + allowed: false, + retryAfterSec: Math.ceil(existing.expires - now) / 1000 + }; + } + existing.count += 1; + return { allowed: true }; + } + + if (buckets.size >= MAX_BUCKETS) { + buckets.clear(); + } + buckets.set(key, { count: 1, expires: now + expiresIn }); + return { allowed: true }; +} + +/** Test seam: wipes all buckets. */ +export function resetBugReportLimiter(): void { + buckets.clear(); +}