Files
HealthLog/src/app/api/bugreport/route.ts
T
Marc-André Bombeck ca03c6099f chore(release): v1.4.24
Security + accessibility hardening release. Closes the highest-priority
findings from a focused security audit of the v1.4.23 release:

- Bearer auth fail-closed when a route declares no scope (api-handler)
- Public /api/send Umami proxy now rate-limits 120 req/min/IP
- Proxy nonce generation switched to btoa(...) for Edge portability
- Session-expiry delete race uses deleteMany + catch (no more 500s)
- POST /api/import returns the standard apiError envelope on 422
- Runtime Docker image pins Prisma 7.8 + worker deps to minors
- Notifications page renders a real load-error alert
- Accessibility: switch aria-labels, htmlFor pairing, mobile admin
  Overview link, deep-link anchors on notification settings
- Chart overlays: per-chart comparisonBaseline pref, BMI/bodyFat/
  sleep/steps overlay keys, TrendCard layout tolerates long labels

Verification: pnpm typecheck && pnpm lint && pnpm test (2244/2244)
&& pnpm format:check all green.
2026-05-12 22:33:35 +02:00

189 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
// Strict image/* data URL (no SVG — it can carry inline JS).
const SCREENSHOT_DATA_URL =
/^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/;
const bugReportSchema = z.object({
description: z.string().min(10, "Description too short").max(5000),
screenshot: z
.string()
.max(7_000_000, "Screenshot too large (max 5 MB)")
.regex(
SCREENSHOT_DATA_URL,
"Screenshot must be a base64-encoded PNG/JPEG/WEBP/GIF data URL",
)
.optional(),
});
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(`bugreport:${user.id}`, 3, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Maximum 3 bug reports per hour", 429);
}
annotate({ action: { name: "bugreport.submit" } });
const appSettings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
githubIssueTokenEncrypted: true,
githubIssueRepo: true,
bugReportEnabled: true,
},
});
// v1.4.3: explicit toggle. When the admin disables bug reports we
// return 503 immediately — the client already renders a "not
// configured" UI for that status, so the report button gracefully
// disappears without needing additional client-side wiring.
if (appSettings && appSettings.bugReportEnabled === false) {
return apiError("Bug reports are disabled by the administrator", 503);
}
let configuredToken: string | null = null;
if (appSettings?.githubIssueTokenEncrypted) {
try {
configuredToken = decrypt(appSettings.githubIssueTokenEncrypted);
} catch {
getEvent()?.addWarning("Failed to decrypt GitHub issue token");
}
}
const ghToken = configuredToken || process.env.GITHUB_ISSUE_TOKEN;
const ghRepo = appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO; // e.g. "owner/repo"
if (!ghToken || !ghRepo) {
// 503 instead of 500: the client keys off status to render the "not
// configured" screen. The user also sees a clear message rather than
// "Internal server error".
return apiError(
"Bug report not configured (GitHub issue token/repository missing in admin settings)",
503,
);
}
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = bugReportSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const { description, screenshot } = parsed.data;
// GitHub issues are maintainer-facing and the repo's primary language is English.
// Use an ISO-8601 timestamp so titles are unambiguous and sortable.
const now = new Date();
const dateStr = now.toISOString().replace("T", " ").slice(0, 16) + " UTC";
const title = `Bug Report – ${dateStr}`;
// Sanitize user-provided content for GitHub markdown. Strip HTML tags
// entirely (prevents `<img onerror=>` etc.) and neutralise markdown link
// targets that start with dangerous schemes like javascript: or data:.
const safeUsername = user.username.replace(/[`*_~<>\[\]|]/g, "");
const safeDescription = description
.replace(/<[^>]*>/g, "") // strip HTML tags
.replace(/\]\(\s*(javascript|data|vbscript|file):/gi, "](about:blank#") // neutralise dangerous markdown link schemes
.replace(/```/g, "\\`\\`\\`")
.slice(0, 5000);
// Wrap the user's description in a fenced code block so any residual
// markdown renders literally rather than as executable markup.
let issueBody = `**Reported by:** ${safeUsername}\n`;
issueBody += `**Date:** ${dateStr}\n\n`;
issueBody += `## Description\n\n\`\`\`\n${safeDescription}\n\`\`\`\n`;
issueBody += `\n---\n*Created via HealthLog bug report*`;
// If screenshot provided, upload to GitHub as a gist or include as base64
// For simplicity, we'll note there's a screenshot but GitHub issues don't support inline base64
if (screenshot) {
issueBody += `\n\n**Screenshot:** A screenshot was attached (see comment).`;
}
// Create the issue
const issueRes = await fetch(
`https://api.github.com/repos/${ghRepo}/issues`,
{
method: "POST",
headers: {
Authorization: `Bearer ${ghToken}`,
"Content-Type": "application/json",
Accept: "application/vnd.github.v3+json",
},
body: JSON.stringify({
title: `[Bug] ${title}`,
body: issueBody,
labels: ["bug", "user-reported"],
}),
},
);
if (!issueRes.ok) {
const errBody = await issueRes.text();
getEvent()?.addWarning("GitHub issue creation failed: " + errBody);
return apiError("Failed to create issue", 500);
}
const issue = (await issueRes.json()) as {
number: number;
html_url: string;
};
// If screenshot, upload as a comment
if (screenshot) {
// Extract just the base64 part
const base64Data = screenshot.replace(/^data:image\/\w+;base64,/, "");
const ext = screenshot.startsWith("data:image/png") ? "png" : "jpg";
// Upload screenshot as issue comment with embedded image
// GitHub doesn't support base64 in comments, so we note the limitation
const commentBody = `### Screenshot\n\n![Screenshot](data:image/${ext};base64,${base64Data.slice(0, 100)}...)\n\n*Note: The full screenshot was truncated due to size limits. Please contact the user directly.*`;
await fetch(
`https://api.github.com/repos/${ghRepo}/issues/${issue.number}/comments`,
{
method: "POST",
headers: {
Authorization: `Bearer ${ghToken}`,
"Content-Type": "application/json",
Accept: "application/vnd.github.v3+json",
},
body: JSON.stringify({ body: commentBody }),
},
);
}
await auditLog("bugreport.submit", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
issueNumber: issue.number,
hasScreenshot: Boolean(screenshot),
},
});
annotate({ meta: { issue_number: issue.number } });
return apiSuccess({
issueNumber: issue.number,
issueUrl: issue.html_url,
});
});