import crypto from "node:crypto"; import { getSiteSetting } from "@/lib/site-settings"; import { getPublicUrl } from "@/lib/storage"; const LABEL_NAMES: Record = { bug: ["bug"], suggestion: ["enhancement"], question: ["question"], }; /** Gitea's issue-create endpoint takes label IDs, not names — resolve by * fetching the repo's existing labels and matching case-insensitively. * A repo with no matching labels (or a lookup failure) just means the * issue is created unlabeled, not that creation fails outright. */ async function resolveLabelIds(baseUrl: string, token: string, repo: string, names: string[]): Promise { if (names.length === 0) return []; try { const res = await fetch(`${baseUrl}/api/v1/repos/${repo}/labels`, { headers: { Authorization: `token ${token}` }, signal: AbortSignal.timeout(10000), }); if (!res.ok) return []; const labels = (await res.json()) as { id: number; name: string }[]; const wanted = new Set(names.map((n) => n.toLowerCase())); return labels.filter((l) => wanted.has(l.name.toLowerCase())).map((l) => l.id); } catch { return []; } } /** Shared by ticket creation and the admin retry action, so a retry embeds * attachments exactly the same way the original attempt would have. Image * attachments embed inline (`![](url)`) so they render as a preview in the * Gitea issue instead of just a clickable link — everything else (PDF, * text, zip) stays a plain link since Gitea can't preview those anyway. */ export function buildGiteaIssueBody(opts: { description: string; userId: string; attachments: { key: string; contentType: string }[]; }): string { const attachmentLines = opts.attachments.map((a) => { const url = getPublicUrl(a.key); return a.contentType.startsWith("image/") ? `![attachment](${url})` : `- ${url}`; }); return [ opts.description, attachmentLines.length > 0 ? `\n**Attachments:**\n${attachmentLines.join("\n")}` : "", `\n---\nReported via Epicure support form by user \`${opts.userId}\`.`, ].join("\n"); } /** * Opens an issue on the configured Gitea repo for a support ticket. Returns * the issue's HTML URL on success, or null if Gitea isn't configured or the * request fails — callers must treat this as best-effort, not block on it, * since the ticket itself is already saved regardless. */ export async function createGiteaIssue(opts: { type: string; title: string; body: string; }): Promise<{ url: string | null; number: number | null; error: string | null }> { const [rawBaseUrl, token, repo] = await Promise.all([ getSiteSetting("GITEA_URL"), getSiteSetting("GITEA_TOKEN"), getSiteSetting("GITEA_REPO"), ]); if (!rawBaseUrl || !token || !repo) { return { url: null, number: null, error: null }; } const baseUrl = rawBaseUrl.replace(/\/$/, ""); try { const labelIds = await resolveLabelIds(baseUrl, token, repo, LABEL_NAMES[opts.type] ?? []); const res = await fetch(`${baseUrl}/api/v1/repos/${repo}/issues`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `token ${token}`, }, body: JSON.stringify({ title: opts.title, body: opts.body, labels: labelIds, }), signal: AbortSignal.timeout(10000), }); if (!res.ok) { const text = await res.text().catch(() => ""); return { url: null, number: null, error: `Gitea returned ${res.status}: ${text.slice(0, 300)}` }; } const issue = (await res.json()) as { html_url?: string; number?: number }; return { url: issue.html_url ?? null, number: issue.number ?? null, error: issue.html_url ? null : "Gitea response had no html_url", }; } catch (err) { // A thrown fetch error (DNS failure, connection refused, timeout, TLS // error) never reaches the res.ok branch above, so its real cause would // otherwise be lost — err.message alone is often just "fetch failed" // with the actual reason nested in err.cause. Log server-side with the // full cause chain, and fold a short form into the returned message so // it also shows up in the admin support view's tooltip, not just logs. const cause = err instanceof Error && err.cause instanceof Error ? err.cause.message : undefined; const message = err instanceof Error ? err.message : "Unknown error"; console.error("[gitea] createGiteaIssue failed", { baseUrl, repo, message, cause }); return { url: null, number: null, error: cause ? `${message}: ${cause}` : message }; } } async function giteaCreds(): Promise<{ baseUrl: string; token: string; repo: string } | null> { const [rawBaseUrl, token, repo] = await Promise.all([ getSiteSetting("GITEA_URL"), getSiteSetting("GITEA_TOKEN"), getSiteSetting("GITEA_REPO"), ]); if (!rawBaseUrl || !token || !repo) return null; return { baseUrl: rawBaseUrl.replace(/\/$/, ""), token, repo }; } /** Posts a comment on the linked Gitea issue. Best-effort — callers must not * block ticket/comment saving on this succeeding. */ export async function postGiteaComment( issueNumber: number, body: string ): Promise<{ id: number | null; error: string | null }> { const creds = await giteaCreds(); if (!creds) return { id: null, error: null }; try { const res = await fetch(`${creds.baseUrl}/api/v1/repos/${creds.repo}/issues/${issueNumber}/comments`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `token ${creds.token}` }, body: JSON.stringify({ body }), signal: AbortSignal.timeout(10000), }); if (!res.ok) { const text = await res.text().catch(() => ""); return { id: null, error: `Gitea returned ${res.status}: ${text.slice(0, 300)}` }; } const comment = (await res.json()) as { id?: number }; return { id: comment.id ?? null, error: null }; } catch (err) { const message = err instanceof Error ? err.message : "Unknown error"; console.error("[gitea] postGiteaComment failed", { issueNumber, message }); return { id: null, error: message }; } } /** Opens or closes the linked Gitea issue to mirror a ticket status change. * Best-effort, same reasoning as postGiteaComment. */ export async function setGiteaIssueState( issueNumber: number, state: "open" | "closed" ): Promise<{ ok: boolean; error: string | null }> { const creds = await giteaCreds(); if (!creds) return { ok: false, error: null }; try { const res = await fetch(`${creds.baseUrl}/api/v1/repos/${creds.repo}/issues/${issueNumber}`, { method: "PATCH", headers: { "Content-Type": "application/json", Authorization: `token ${creds.token}` }, body: JSON.stringify({ state }), signal: AbortSignal.timeout(10000), }); if (!res.ok) { const text = await res.text().catch(() => ""); return { ok: false, error: `Gitea returned ${res.status}: ${text.slice(0, 300)}` }; } return { ok: true, error: null }; } catch (err) { const message = err instanceof Error ? err.message : "Unknown error"; console.error("[gitea] setGiteaIssueState failed", { issueNumber, state, message }); return { ok: false, error: message }; } } /** Gitea signs webhook deliveries with HMAC-SHA256 hex digest of the raw * body (X-Gitea-Signature) — no timestamp prefix, unlike Stripe. */ export function verifyGiteaSignature(rawBody: string, signature: string, secret: string): boolean { const expected = crypto.createHmac("sha256", secret).update(rawBody, "utf8").digest("hex"); const expectedBuf = Buffer.from(expected, "hex"); let providedBuf: Buffer; try { providedBuf = Buffer.from(signature, "hex"); } catch { return false; } if (providedBuf.length !== expectedBuf.length) return false; return crypto.timingSafeEqual(expectedBuf, providedBuf); }