112 lines
4.5 KiB
TypeScript
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
}
|
|
|
|
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);
|
|
}
|
|
});
|