Files
HealthLog/src/lib/feedback/publish-github.ts
T
Marc-André BombeckandClaude Opus 4.7 425503e095 feat(security): safeFetch wrapper with manual redirect and timeout defaults
Closes issue 218. Flips the convention from "remember to opt in to
redirect: manual" to "remember to opt out". Every outbound fetch that
doesn't pin a hard-coded host now routes through safeFetch and inherits
AbortSignal.timeout(15_000) so a tar-pit upstream can no longer pin a
worker indefinitely. The local AI client also gates on
requirePublicHost: true since its host is user-supplied; operators
running self-hosted Ollama / LM Studio on an RFC1918 address keep their
escape hatch via ALLOW_LOCAL_AI_PRIVATE_HOSTS=true (also enforced at
write-time in /api/user/ai-provider).

14 outbound call sites migrated. The four that already pinned manual
redirect (the historic templates) keep their semantics; the wrapper
preserves an explicit init.redirect. Adds undici as an explicit
dependency so the connect-time pin landing alongside this can rely on
the documented API rather than a transitive hop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:11 +02:00

175 lines
5.2 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.
/**
* GitHub publisher for feedback items.
*
* Centralises the GitHub Issues API interaction so /api/bugreport (legacy
* direct-submit) and /api/admin/feedback/[id]/github (admin-promotes-feedback)
* share one implementation. Reads the configured token + repo from AppSettings
* (encrypted) with env-var fallback.
*/
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
export interface FeedbackInput {
category: string;
subject: string;
description: string;
username: string;
metadata?: unknown;
}
export interface PublishResult {
issueNumber: number;
issueUrl: string;
}
export class GithubPublishError extends Error {
constructor(
message: string,
public status: number,
) {
super(message);
this.name = "GithubPublishError";
}
}
/**
* Resolves the configured GitHub token + repo from AppSettings (or env).
* Returns null if no configuration is present (caller should treat as 503).
*/
async function getGithubConfig(): Promise<{
token: string;
repo: string;
} | null> {
const appSettings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
githubIssueTokenEncrypted: true,
githubIssueRepo: true,
},
});
let configuredToken: string | null = null;
if (appSettings?.githubIssueTokenEncrypted) {
try {
configuredToken = decrypt(appSettings.githubIssueTokenEncrypted);
} catch {
getEvent()?.addWarning("Failed to decrypt GitHub issue token");
}
}
const token = configuredToken || process.env.GITHUB_ISSUE_TOKEN || "";
const repo =
appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO || "";
if (!token || !repo) return null;
return { token, repo };
}
/**
* Sanitise a free-text field for GitHub markdown (strip control chars,
* neutralise fenced code blocks, hard-cap length).
*/
function sanitize(text: string, max = 5000): string {
return text.replace(/```/g, "\\`\\`\\`").slice(0, max);
}
/**
* Escape a string for safe inclusion in a `RegExp(..)` constructor — the
* standard "don't let an attacker turn input into regex metacharacters"
* helper. Used to redact secrets from logged error bodies.
*/
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Publishes a feedback item as a GitHub Issue. Returns issue number + URL.
* Throws GithubPublishError on API failure.
*/
export async function publishFeedbackToGithub(
input: FeedbackInput,
): Promise<PublishResult> {
const config = await getGithubConfig();
if (!config) {
throw new GithubPublishError(
"GitHub publishing is not configured (missing token or repository)",
503,
);
}
const now = new Date();
const dateStr = now.toISOString().replace("T", " ").slice(0, 16) + " UTC";
const safeUsername = input.username.replace(/[`*_~<>\[\]|]/g, "");
const safeSubject = sanitize(input.subject, 200);
const safeDescription = sanitize(input.description);
const categoryLabelMap: Record<string, string> = {
BUG: "bug",
FEATURE_REQUEST: "enhancement",
QUESTION: "question",
OTHER: "feedback",
};
const categoryLabel = categoryLabelMap[input.category] ?? "feedback";
let body = `**Reported by:** ${safeUsername}\n`;
body += `**Category:** ${input.category}\n`;
body += `**Date:** ${dateStr}\n\n`;
body += `## Description\n\n${safeDescription}\n`;
if (input.metadata && typeof input.metadata === "object") {
const meta = input.metadata as Record<string, unknown>;
const lines: string[] = [];
for (const [key, value] of Object.entries(meta)) {
if (value == null) continue;
const v = String(value).slice(0, 200);
lines.push(`- **${key}:** ${v.replace(/[`<>]/g, "")}`);
}
if (lines.length > 0) {
body += `\n## Context\n\n${lines.join("\n")}\n`;
}
}
body += `\n\n---\n*Created via HealthLog feedback*`;
const title = `[${categoryLabel}] ${safeSubject || `Feedback – ${dateStr}`}`;
const res = await safeFetch(
`https://api.github.com/repos/${config.repo}/issues`,
{
method: "POST",
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
Accept: "application/vnd.github.v3+json",
},
body: JSON.stringify({
title,
body,
labels: [categoryLabel, "user-reported"],
}),
},
);
if (!res.ok) {
const errBody = await res.text();
// Strip the GitHub PAT before logging — GitHub error responses can echo
// request headers in some failure modes, and Wide Events flow to Loki
// unredacted. Keep the response shape useful for debugging without
// leaking the credential.
const sanitisedErr = errBody
.replace(new RegExp(escapeRegex(config.token), "g"), "[REDACTED]")
.slice(0, 500);
getEvent()?.addWarning("GitHub issue creation failed: " + sanitisedErr);
throw new GithubPublishError(
`Failed to create GitHub issue (HTTP ${res.status})`,
res.status === 401 || res.status === 403 ? res.status : 502,
);
}
const issue = (await res.json()) as { number: number; html_url: string };
return { issueNumber: issue.number, issueUrl: issue.html_url };
}