feat: global bug report button — floating widget on every page

Adds a small bug icon (bottom-right) that opens a modal to collect
errors, device info, comments, and optional screenshots, then emails
the report to jeff@jeffemmett.com via the existing SMTP transport.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-09 14:03:27 -04:00
parent 83267a2209
commit 85cf54b811
4 changed files with 269 additions and 1 deletions

111
server/bug-report-routes.ts Normal file
View File

@ -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, "&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);
}
});

View File

@ -107,6 +107,7 @@ import { registerUserConnection, unregisterUserConnection, notify } from "./noti
import { SystemClock } from "./clock-service"; import { SystemClock } from "./clock-service";
import type { ClockPayload } from "./clock-service"; import type { ClockPayload } from "./clock-service";
import { miRoutes } from "./mi-routes"; import { miRoutes } from "./mi-routes";
import { bugReportRouter } from "./bug-report-routes";
// ── Process-level error safety net (prevent crash on unhandled socket errors) ── // ── Process-level error safety net (prevent crash on unhandled socket errors) ──
process.on('uncaughtException', (err) => { process.on('uncaughtException', (err) => {
@ -524,6 +525,9 @@ app.route("/api/mi", miRoutes);
app.route("/rtasks/check", checklistCheckRoutes); app.route("/rtasks/check", checklistCheckRoutes);
app.route("/api/rtasks", checklistApiRoutes); app.route("/api/rtasks", checklistApiRoutes);
// ── Bug Report API ──
app.route("/api/bug-report", bugReportRouter);
// ── Magic Link Responses (top-level, bypasses space auth) ── // ── Magic Link Responses (top-level, bypasses space auth) ──
app.route("/respond", magicLinkRoutes); app.route("/respond", magicLinkRoutes);

View File

@ -41,7 +41,7 @@ const SMTP_PASS = process.env.SMTP_PASS || "";
let _smtpTransport: any = null; let _smtpTransport: any = null;
async function getSmtpTransport() { export async function getSmtpTransport() {
if (_smtpTransport) return _smtpTransport; if (_smtpTransport) return _smtpTransport;
const isInternal = SMTP_HOST.includes('mailcow') || SMTP_HOST.includes('postfix'); const isInternal = SMTP_HOST.includes('mailcow') || SMTP_HOST.includes('postfix');
if (!SMTP_PASS && !isInternal) return null; if (!SMTP_PASS && !isInternal) return null;

View File

@ -289,6 +289,7 @@ export function renderShell(opts: ShellOptions): string {
<style>${INFO_PANEL_CSS}</style> <style>${INFO_PANEL_CSS}</style>
<style>${SUBNAV_CSS}</style> <style>${SUBNAV_CSS}</style>
<style>${TABBAR_CSS}</style> <style>${TABBAR_CSS}</style>
<style>${BUG_REPORT_CSS}</style>
</head> </head>
<body data-space-visibility="${escapeAttr(spaceVisibility)}" data-space-slug="${escapeAttr(spaceSlug)}" data-module-id="${escapeAttr(moduleId)}" data-scope-overrides="${escapeAttr(JSON.stringify(scopeOverrides))}"> <body data-space-visibility="${escapeAttr(spaceVisibility)}" data-space-slug="${escapeAttr(spaceSlug)}" data-module-id="${escapeAttr(moduleId)}" data-scope-overrides="${escapeAttr(JSON.stringify(scopeOverrides))}">
<div class="rspace-banner rspace-banner--install" id="pwa-install-banner" style="display:none"> <div class="rspace-banner rspace-banner--install" id="pwa-install-banner" style="display:none">
@ -1566,6 +1567,93 @@ export function renderShell(opts: ShellOptions): string {
})(); })();
} }
</script> </script>
${renderBugReportWidget()}
<script>(function(){
// ── Bug Report: error ring buffer ──
var _bugErrors = [];
window.addEventListener('error', function(e) {
_bugErrors.push(e.message + (e.filename ? ' (' + e.filename + ':' + e.lineno + ')' : ''));
if (_bugErrors.length > 5) _bugErrors.shift();
});
window.addEventListener('unhandledrejection', function(e) {
_bugErrors.push('Promise: ' + (e.reason?.message || e.reason || 'unknown'));
if (_bugErrors.length > 5) _bugErrors.shift();
});
var btn = document.getElementById('rspace-bug-btn');
var overlay = document.getElementById('rspace-bug-overlay');
var modal = document.getElementById('rspace-bug-modal');
var errField = document.getElementById('bug-errors');
var deviceField = document.getElementById('bug-device');
var commentsField = document.getElementById('bug-comments');
var fileInput = document.getElementById('bug-screenshot');
var submitBtn = document.getElementById('rspace-bug-submit');
var statusEl = document.getElementById('rspace-bug-status');
function openModal() {
errField.value = _bugErrors.length ? _bugErrors.join('\\n') : '';
deviceField.value = navigator.userAgent;
commentsField.value = '';
fileInput.value = '';
statusEl.textContent = '';
submitBtn.disabled = false;
overlay.classList.add('open');
}
function closeModal() { overlay.classList.remove('open'); }
btn.addEventListener('click', openModal);
overlay.addEventListener('click', function(e) { if (e.target === overlay) closeModal(); });
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && overlay.classList.contains('open')) closeModal();
});
submitBtn.addEventListener('click', function() {
var file = fileInput.files && fileInput.files[0];
if (file && file.size > 3 * 1024 * 1024) {
statusEl.textContent = 'Screenshot exceeds 3 MB limit';
statusEl.style.color = 'var(--rs-error, #f87171)';
return;
}
submitBtn.disabled = true;
statusEl.textContent = 'Sending...';
statusEl.style.color = 'var(--rs-text-muted, #94a3b8)';
function send(screenshotData) {
fetch('/api/bug-report', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userAgent: navigator.userAgent,
url: location.href,
errors: _bugErrors.slice(),
comments: commentsField.value,
screenshot: screenshotData || undefined,
}),
}).then(function(r) {
if (r.ok) {
statusEl.textContent = 'Report sent — thank you!';
statusEl.style.color = 'var(--rs-primary, #14b8a6)';
setTimeout(closeModal, 1500);
} else {
return r.json().then(function(d) { throw new Error(d.error || 'Failed'); });
}
}).catch(function(err) {
statusEl.textContent = err.message || 'Failed to send';
statusEl.style.color = 'var(--rs-error, #f87171)';
submitBtn.disabled = false;
});
}
if (file) {
var reader = new FileReader();
reader.onload = function() { send(reader.result); };
reader.onerror = function() { send(null); };
reader.readAsDataURL(file);
} else {
send(null);
}
});
})()</script>
${scripts} ${scripts}
</body> </body>
</html>`); </html>`);
@ -2196,6 +2284,71 @@ const TABBAR_CSS = `
} }
`; `;
// ── Bug report floating button + modal ──
const BUG_REPORT_CSS = `
#rspace-bug-btn {
position: fixed; bottom: 20px; right: 20px; z-index: 9990;
width: 36px; height: 36px; border-radius: 50%;
background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-border, #334155);
color: var(--rs-text-muted, #94a3b8); cursor: pointer;
display: flex; align-items: center; justify-content: center;
opacity: 0.45; transition: opacity 0.2s, transform 0.2s;
padding: 0; box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
#rspace-bug-btn:hover { opacity: 1; transform: scale(1.1); }
#rspace-bug-btn svg { width: 18px; height: 18px; }
html.rspace-embedded #rspace-bug-btn { display: none !important; }
#rspace-bug-overlay {
display: none; position: fixed; inset: 0; z-index: 10002;
background: rgba(0,0,0,0.5); backdrop-filter: blur(4px);
}
#rspace-bug-overlay.open { display: flex; align-items: center; justify-content: center; }
#rspace-bug-modal {
z-index: 10003; width: 90vw; max-width: 480px; max-height: 85vh; overflow-y: auto;
background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-border, #334155);
border-radius: 12px; padding: 20px; color: var(--rs-text, #e2e8f0);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
#rspace-bug-modal h3 { margin: 0 0 16px; font-size: 16px; color: var(--rs-error, #f87171); }
#rspace-bug-modal label { display: block; font-size: 12px; font-weight: 600; color: var(--rs-text-muted, #94a3b8); margin: 10px 0 4px; }
#rspace-bug-modal textarea,
#rspace-bug-modal input[type="text"] {
width: 100%; box-sizing: border-box; padding: 8px; font-size: 13px;
background: var(--rs-bg, #0f172a); color: var(--rs-text, #e2e8f0);
border: 1px solid var(--rs-border, #334155); border-radius: 6px; resize: vertical;
}
#rspace-bug-modal input[type="file"] { font-size: 12px; color: var(--rs-text-muted, #94a3b8); margin-top: 2px; }
#rspace-bug-submit {
margin-top: 14px; width: 100%; padding: 10px; font-size: 14px; font-weight: 600;
background: var(--rs-primary, #14b8a6); color: #fff; border: none; border-radius: 6px; cursor: pointer;
}
#rspace-bug-submit:hover { filter: brightness(1.1); }
#rspace-bug-submit:disabled { opacity: 0.5; cursor: not-allowed; }
#rspace-bug-status { margin-top: 8px; font-size: 12px; text-align: center; min-height: 1.2em; }
`;
function renderBugReportWidget(): string {
return `<button id="rspace-bug-btn" title="Report a bug" aria-label="Report a bug">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2l1.88 1.88"/><path d="M14.12 3.88 16 2"/><path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1"/><path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6"/><path d="M12 20v-9"/><path d="M6.53 9C4.6 8.8 3 7.1 3 5"/><path d="M6 13H2"/><path d="M3 21c0-2.1 1.7-3.9 3.8-4"/><path d="M20.97 5c0 2.1-1.6 3.8-3.5 4"/><path d="M22 13h-4"/><path d="M17.2 17c2.1.1 3.8 1.9 3.8 4"/></svg>
</button>
<div id="rspace-bug-overlay">
<div id="rspace-bug-modal">
<h3>Report a Bug</h3>
<label for="bug-errors">Recent errors</label>
<textarea id="bug-errors" rows="3" readonly placeholder="No errors captured"></textarea>
<label for="bug-device">Device info</label>
<input type="text" id="bug-device" readonly>
<label for="bug-comments">What happened?</label>
<textarea id="bug-comments" rows="4" placeholder="Describe what you were doing when the issue occurred..."></textarea>
<label for="bug-screenshot">Screenshot (optional, max 3 MB)</label>
<input type="file" id="bug-screenshot" accept="image/*">
<button id="rspace-bug-submit">Send Report</button>
<div id="rspace-bug-status"></div>
</div>
</div>`;
}
function renderTabBar(tabs: Array<{ id: string; label: string; icon?: string }>, activeTab: string | undefined, basePath: string): string { function renderTabBar(tabs: Array<{ id: string; label: string; icon?: string }>, activeTab: string | undefined, basePath: string): string {
if (tabs.length === 0) return ''; if (tabs.length === 0) return '';