rspace-online/server/bug-report-routes.ts

112 lines
4.5 KiB
TypeScript

/**
* Bug Report API — accepts user-submitted bug reports and emails them.
*/
import { Hono } from "hono";
import { getSmtpTransport } from "./notification-service";
export const bugReportRouter = new Hono();
const MAX_SCREENSHOT_BYTES = 3 * 1024 * 1024; // 3 MB
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
bugReportRouter.post("/", async (c) => {
let body: any;
try {
body = await c.req.json();
} catch {
return c.json({ error: "Invalid JSON" }, 400);
}
const { userAgent, url, errors, comments, screenshot } = body as {
userAgent?: string;
url?: string;
errors?: string[];
comments?: string;
screenshot?: string; // data URL
};
if (!comments?.trim() && (!errors || errors.length === 0)) {
return c.json({ error: "Please provide comments or error details" }, 400);
}
// Screenshot handling
let attachments: any[] = [];
let screenshotCid = "";
if (screenshot && typeof screenshot === "string" && screenshot.startsWith("data:image/")) {
// Validate size (base64 is ~4/3 of raw, so check decoded)
const base64Part = screenshot.split(",")[1];
if (!base64Part) return c.json({ error: "Invalid screenshot data URL" }, 400);
const rawBytes = Buffer.from(base64Part, "base64");
if (rawBytes.length > MAX_SCREENSHOT_BYTES) {
return c.json({ error: "Screenshot exceeds 3 MB limit" }, 413);
}
// Determine extension from mime
const mimeMatch = screenshot.match(/^data:image\/(png|jpeg|jpg|gif|webp);/);
const ext = mimeMatch ? mimeMatch[1].replace("jpeg", "jpg") : "png";
const filename = `bugreport-${Date.now()}.${ext}`;
// Save to generated files
const savePath = `/data/files/generated/${filename}`;
try {
await Bun.write(savePath, rawBytes);
} catch {
// Non-fatal — still send email without saved file
}
screenshotCid = `screenshot-${Date.now()}`;
attachments = [{
filename,
content: rawBytes,
cid: screenshotCid,
contentType: `image/${ext === "jpg" ? "jpeg" : ext}`,
}];
}
// Build HTML email
const errorList = (errors && errors.length > 0)
? errors.map(e => `<li style="margin:4px 0;font-size:13px;color:#fca5a5;">${escapeHtml(e)}</li>`).join("")
: "";
const html = `
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:600px;margin:0 auto;padding:24px;">
<div style="background:#1e293b;border-radius:10px;padding:20px;color:#e2e8f0;">
<h2 style="margin:0 0 12px;font-size:18px;color:#f87171;">Bug Report</h2>
<table style="width:100%;border-collapse:collapse;font-size:13px;color:#94a3b8;">
<tr><td style="padding:4px 8px 4px 0;font-weight:600;color:#cbd5e1;white-space:nowrap;">Page URL</td><td style="padding:4px 0;">${escapeHtml(url || "unknown")}</td></tr>
<tr><td style="padding:4px 8px 4px 0;font-weight:600;color:#cbd5e1;white-space:nowrap;">Device</td><td style="padding:4px 0;">${escapeHtml(userAgent || "unknown")}</td></tr>
</table>
${errorList ? `<div style="margin-top:12px;"><strong style="color:#fca5a5;font-size:13px;">Recent Errors:</strong><ul style="margin:4px 0 0;padding-left:20px;">${errorList}</ul></div>` : ""}
${comments?.trim() ? `<div style="margin-top:12px;"><strong style="color:#cbd5e1;font-size:13px;">Comments:</strong><p style="margin:4px 0 0;font-size:14px;color:#e2e8f0;white-space:pre-wrap;">${escapeHtml(comments)}</p></div>` : ""}
${screenshotCid ? `<div style="margin-top:16px;"><strong style="color:#cbd5e1;font-size:13px;">Screenshot:</strong><br><img src="cid:${screenshotCid}" style="margin-top:8px;max-width:100%;border-radius:6px;border:1px solid #334155;"></div>` : ""}
</div>
<p style="margin:12px 0 0;font-size:11px;color:#64748b;text-align:center;">Sent from rSpace Bug Reporter</p>
</div>`;
try {
const transport = await getSmtpTransport();
if (!transport) {
console.error("[bug-report] No SMTP transport available");
return c.json({ error: "Email service unavailable" }, 503);
}
await transport.sendMail({
from: "rSpace Bugs <bugs@rspace.online>",
to: "jeff@jeffemmett.com",
subject: `[Bug] ${url || "rSpace"}${(comments || "").slice(0, 60) || "Error report"}`,
html,
attachments,
});
console.log(`[bug-report] Report sent for ${url}`);
return c.json({ ok: true });
} catch (err: any) {
console.error("[bug-report] Failed to send:", err.message);
return c.json({ error: "Failed to send report" }, 500);
}
});