nook bug reporting handler

This commit is contained in:
2026-09-01 17:44:20 -04:00
parent 1ee61d8fe4
commit fd68efdb78
4 changed files with 336 additions and 10 deletions

View File

@@ -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<string, string> = {}) {
return {
request: {
json: async () => body,
headers: new Headers(headers)
}
};
}
const validPayload = {
appVersion: "The Nook 0.2.0 (12)",
title: "Island flickers",
description: "It happened <twice> & 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("<b>builtin:</b> false");
expect(email.html).toContain("It happened &lt;twice&gt; &amp; 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);
});
});

View File

@@ -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<typeof reportSchema>;
const contactRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// One escaping contract for every interpolated string (many call sites).
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function renderBody(report: Report): string {
const machineLines = Object.entries(report.machine).map(
([key, value]) => `<b>${escapeHtml(key)}:</b> ${escapeHtml(String(value))}`
);
const displayLines = report.displays.map((display) =>
[
`<b>name:</b> ${escapeHtml(display.name)}`,
`<b>boundsPx:</b> ${escapeHtml(display.boundsPx)}`,
`<b>scale:</b> ${escapeHtml(display.scale)}`,
`<b>hz:</b> ${escapeHtml(display.hz)}`,
`<b>builtin:</b> ${escapeHtml(String(display.builtin))}`
].join(" · ")
);
return [
`<h2>The Nook bug report</h2>`,
`<h3>“${escapeHtml(report.title)}”</h3>`,
report.description
? `<h3>What happened</h3><div>${escapeHtml(report.description).replace(/\r\n|\n|\r/g, "<br>")}</div>`
: "",
report.contact && contactRe.test(report.contact)
? `<p><b>Reply-to:</b> ${escapeHtml(report.contact)}</p>`
: "",
`<h3>Snapshot</h3>`,
report.appVersion ? `<p><b>${escapeHtml(report.appVersion)}</b></p>` : "",
machineLines.length > 0 ? `<p>${machineLines.join("<br>")}</p>` : "",
displayLines.length > 0
? `<h3>Displays</h3><div>${displayLines.map((line) => `<div>${line}</div>`).join("")}</div>`
: ""
]
.filter((chunk) => chunk !== "")
.join("\n");
}
export async function POST(event: APIEvent): Promise<Response> {
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 });
}

View File

@@ -49,7 +49,10 @@ export default function NookCheckout() {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ turnstileToken: turnstileToken() }) 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) { if (!res.ok || !data.checkoutUrl) {
setError(data.error ?? "Unable to start checkout. Please try again."); setError(data.error ?? "Unable to start checkout. Please try again.");
return; return;
@@ -74,21 +77,20 @@ export default function NookCheckout() {
<div class="border-overlay0 bg-surface0 rounded-xl border p-8 shadow-2xl"> <div class="border-overlay0 bg-surface0 rounded-xl border p-8 shadow-2xl">
<div class="mb-2 flex items-end gap-3"> <div class="mb-2 flex items-end gap-3">
<div class="text-text text-4xl font-extrabold"> <div class="text-text text-4xl font-extrabold">
$10<span class="text-subtext0 text-base font-normal"> one-time</span> $10
<span class="text-subtext0 text-base font-normal"> one-time</span>
</div> </div>
<div class="text-subtext0 mb-1 text-2xl font-medium line-through"> <div class="text-subtext0 mb-1 text-2xl font-medium line-through">
$15 $15
</div> </div>
</div> </div>
<p class="mb-6 text-sm font-medium"> <p class="mb-6 text-sm font-medium">
<span <span class="border-overlay0 bg-surface0 text-subtext0 inline-block rounded-full border px-3 py-1 text-xs font-semibold tracking-wide">
class="border-overlay0 bg-surface0 text-subtext0 inline-block rounded-full border px-3 py-1 text-xs font-semibold tracking-wide"
>
Beta pricing 33% off Beta pricing 33% off
</span> </span>
</p> </p>
<ul class="text-subtext0 mb-6 space-y-2 text-sm"> <ul class="text-subtext0 mb-6 space-y-2 text-sm">
<li> Activate on up to 3 of your own Macs</li> <li> Activate on up to 3 devices</li>
<li> No subscription, ever</li> <li> No subscription, ever</li>
<li> Key delivered by email + on this page</li> <li> Key delivered by email + on this page</li>
</ul> </ul>
@@ -106,11 +108,10 @@ export default function NookCheckout() {
Pay $10 Pay $10
</Button> </Button>
{error() && ( {error() && <p class="text-red mt-4 text-sm">{error()}</p>}
<p class="text-red mt-4 text-sm">{error()}</p>
)}
<p class="text-subtext1 mt-4 text-xs"> <p class="text-subtext1 mt-4 text-xs">
Billed once through Stripe. License delivered immediately after payment. Billed once through Stripe. License delivered immediately after
payment.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -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<string, RateWindow>();
/** 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();
}