mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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>
175 lines
5.2 KiB
TypeScript
175 lines
5.2 KiB
TypeScript
/**
|
||
* 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 };
|
||
}
|