nook bug reporting handler
This commit is contained in:
137
src/routes/api/the-nook/report.test.ts
Normal file
137
src/routes/api/the-nook/report.test.ts
Normal 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 <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);
|
||||
});
|
||||
});
|
||||
131
src/routes/api/the-nook/report.ts
Normal file
131
src/routes/api/the-nook/report.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
@@ -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() {
|
||||
<div class="border-overlay0 bg-surface0 rounded-xl border p-8 shadow-2xl">
|
||||
<div class="mb-2 flex items-end gap-3">
|
||||
<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 class="text-subtext0 mb-1 text-2xl font-medium line-through">
|
||||
$15
|
||||
</div>
|
||||
</div>
|
||||
<p class="mb-6 text-sm font-medium">
|
||||
<span
|
||||
class="border-overlay0 bg-surface0 text-subtext0 inline-block rounded-full border px-3 py-1 text-xs font-semibold tracking-wide"
|
||||
>
|
||||
<span 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
|
||||
</span>
|
||||
</p>
|
||||
<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>• Key delivered by email + on this page</li>
|
||||
</ul>
|
||||
@@ -106,11 +108,10 @@ export default function NookCheckout() {
|
||||
Pay $10
|
||||
</Button>
|
||||
|
||||
{error() && (
|
||||
<p class="text-red mt-4 text-sm">{error()}</p>
|
||||
)}
|
||||
{error() && <p class="text-red mt-4 text-sm">{error()}</p>}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
57
src/server/bug-report-rate-limit.ts
Normal file
57
src/server/bug-report-rate-limit.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user