diff --git a/server/bug-report-routes.ts b/server/bug-report-routes.ts new file mode 100644 index 00000000..15c4bf43 --- /dev/null +++ b/server/bug-report-routes.ts @@ -0,0 +1,111 @@ +/** + * 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, """); +} + +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 => `
  • ${escapeHtml(e)}
  • `).join("") + : ""; + + const html = ` +
    +
    +

    Bug Report

    + + + +
    Page URL${escapeHtml(url || "unknown")}
    Device${escapeHtml(userAgent || "unknown")}
    + ${errorList ? `
    Recent Errors:
      ${errorList}
    ` : ""} + ${comments?.trim() ? `
    Comments:

    ${escapeHtml(comments)}

    ` : ""} + ${screenshotCid ? `
    Screenshot:
    ` : ""} +
    +

    Sent from rSpace Bug Reporter

    +
    `; + + 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 ", + 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); + } +}); diff --git a/server/index.ts b/server/index.ts index c57f031b..20b2aa56 100644 --- a/server/index.ts +++ b/server/index.ts @@ -107,6 +107,7 @@ import { registerUserConnection, unregisterUserConnection, notify } from "./noti import { SystemClock } from "./clock-service"; import type { ClockPayload } from "./clock-service"; import { miRoutes } from "./mi-routes"; +import { bugReportRouter } from "./bug-report-routes"; // ── Process-level error safety net (prevent crash on unhandled socket errors) ── process.on('uncaughtException', (err) => { @@ -524,6 +525,9 @@ app.route("/api/mi", miRoutes); app.route("/rtasks/check", checklistCheckRoutes); app.route("/api/rtasks", checklistApiRoutes); +// ── Bug Report API ── +app.route("/api/bug-report", bugReportRouter); + // ── Magic Link Responses (top-level, bypasses space auth) ── app.route("/respond", magicLinkRoutes); diff --git a/server/notification-service.ts b/server/notification-service.ts index ef748780..ce2b64ca 100644 --- a/server/notification-service.ts +++ b/server/notification-service.ts @@ -41,7 +41,7 @@ const SMTP_PASS = process.env.SMTP_PASS || ""; let _smtpTransport: any = null; -async function getSmtpTransport() { +export async function getSmtpTransport() { if (_smtpTransport) return _smtpTransport; const isInternal = SMTP_HOST.includes('mailcow') || SMTP_HOST.includes('postfix'); if (!SMTP_PASS && !isInternal) return null; diff --git a/server/shell.ts b/server/shell.ts index c74d30bc..3c66c027 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -289,6 +289,7 @@ export function renderShell(opts: ShellOptions): string { +