import "server-only"; import { createHash, randomBytes, randomUUID } from "node:crypto"; import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { after } from "next/server"; import { admin, required, user } from "./supabase"; import { accountProfile } from "./profile"; import { requestOrigin } from "./request-origin"; import { ServiceError } from "./service-error"; import { bugReportSchema, bugMessageSchema, type BugReportRecord, type BugReportView, } from "../shared/bug-reports"; import { supportScreenshot } from "./support-images"; import { supportReady, syncBugAfterResponse } from "./support-sync"; import { scheduleBugCleanup, reconcileBugStatus } from "./bug-lifecycle"; type Dependencies = { jsonBody: (request: NextRequest) => Promise; limit: (key: string, count: number, seconds: number) => Promise; }; export async function bugReportApi( request: NextRequest, path: string[], { jsonBody, limit }: Dependencies, ) { const db = admin(), account = await user(); const profile = account ? await accountProfile(account) : null; const saved = request.cookies.get("mg_support")?.value; const secret = saved ?? randomBytes(32).toString("hex"); const hash = createHash("sha256").update(secret).digest("hex"); const respond = (body: unknown) => { const response = NextResponse.json(body, { headers: { "Cache-Control": "no-store" }, }); if (!saved) response.cookies.set("mg_support", secret, { httpOnly: true, sameSite: "lax", secure: requestOrigin(request, process.env.MINEGEO_URL).startsWith( "https:", ), path: "/", maxAge: 60 * 60 * 24 * 180, }); return response; }; const owns = (record: BugReportRecord) => record.owner_id ? record.owner_id === account?.id : !!saved && record.access_hash === hash; async function owned(id: string) { const result = await db .from("bug_reports") .select("*") .eq("id", id) .maybeSingle(); if (result.error) required(result); const record = result.data; if (!record) throw new ServiceError("Report not found", 404); if (!owns(record)) throw new ServiceError("Report not found", 404); return record; } async function view(record: BugReportRecord): Promise { const messages = required( await db .from("bug_report_messages") .select("id,kind,body,author_name,delivered,created_at") .eq("report_id", record.id) .order("created_at", { ascending: false }) .limit(200), ).reverse(); const screenshot = record.screenshot_path ? required( await db.storage .from("minegeo-support") .createSignedUrl(record.screenshot_path, 600), ).signedUrl : null; return { id: record.id, title: record.title, description: record.description, page_path: record.page_path, state: record.state, closed_at: record.closed_at, delete_after: record.delete_after, linear_done_synced: record.linear_done_synced, cleanup_error: record.cleanup_error, linear_identifier: record.linear_identifier, glow_requested: record.glow_requested, created_at: record.created_at, screenshot, messages, isGuest: record.owner_id === null, authorAppearance: record.owner_id ? (profile ?? undefined) : undefined, canClaim: record.owner_id === null && !!account, }; } if (path.length === 1 && request.method === "GET") { const rows = required( await db .from("bug_reports") .select("id,title,state,created_at,linear_identifier") .or( account ? `owner_id.eq.${account.id},and(owner_id.is.null,access_hash.eq.${hash})` : `and(owner_id.is.null,access_hash.eq.${hash})`, ) .order("created_at", { ascending: false }) .limit(50), ); after(async () => { for (let i = 0; i < rows.length; i += 5) await Promise.allSettled( rows.slice(i, i + 5).map((row) => reconcileBugStatus(row.id)), ); }); return respond({ ready: supportReady(), signedIn: !!account, displayName: profile?.display_name ?? null, nameGlow: profile?.name_glow ?? false, reports: rows, }); } if (request.method === "POST" && path.length === 1) { if (!saved) throw new ServiceError("Open support before sending a report", 409); const body = bugReportSchema.parse(await jsonBody(request)); const lookup = await db .from("bug_reports") .select("*") .eq("client_id", body.clientId) .maybeSingle(); if (lookup.error) required(lookup); let record = lookup.data; if (record && !owns(record)) throw new ServiceError("Report not found", 404); if (!record) { if (!supportReady()) throw new ServiceError( "Bug reporting is temporarily unavailable. Please try again later.", 503, ); const ip = request.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? "unknown"; await limit( "support:create:" + createHash("sha256").update(ip).digest("hex"), 20, 3600, ); await limit("support:create:" + hash, 5, 3600); const inserted = await db .from("bug_reports") .upsert( { id: randomUUID(), client_id: body.clientId, owner_id: account?.id ?? null, access_hash: hash, display_name: profile?.display_name ?? body.displayName ?? "Guest explorer", title: body.title, description: body.description, page_path: body.pagePath, screenshot_expected: body.screenshot, }, { onConflict: "client_id", ignoreDuplicates: true }, ) .select("id"); required(inserted); record = required( await db .from("bug_reports") .select("*") .eq("client_id", body.clientId) .single(), ); if (!owns(record)) throw new ServiceError("Report not found", 404); } const upload = record.state === "draft" && record.screenshot_expected ? required( await db.storage .from("minegeo-support") .createSignedUploadUrl(`${record.id}/input`, { upsert: true }), ).signedUrl : null; return respond({ id: record.id, state: record.state, upload }); } const id = z.string().uuid().parse(path[1]); const record = await owned(id); if (path.length === 2 && request.method === "GET") { try { await reconcileBugStatus(id); } catch { console.error("Bug report status will retry."); } if (record.state !== "draft") syncBugAfterResponse(id); if (record.state === "done") await scheduleBugCleanup(id); return respond(await view(await owned(id))); } if (path.length !== 3 || request.method !== "POST") return NextResponse.json({ error: "Not found" }, { status: 404 }); if (path[2] === "submit") { if (record.state === "draft") { let screenshotPath: string | null = null; if (record.screenshot_expected) { const image = required( await db.storage.from("minegeo-support").download(`${id}/input`), ); const normalized = await supportScreenshot( new Uint8Array(await image.arrayBuffer()), ); screenshotPath = `${id}/screenshot.webp`; const uploaded = await db.storage .from("minegeo-support") .upload(screenshotPath, normalized, { contentType: "image/webp", upsert: false, }); if ( uploaded.error && uploaded.error.message !== "The resource already exists" ) throw new ServiceError( "Screenshot upload failed. Please try again.", 503, ); } // Stable initial message ID and conditional transition make submit retries safe. required( await db .from("bug_report_messages") .upsert( { id, report_id: id, kind: "user", body: record.description, author_name: record.display_name, }, { onConflict: "id", ignoreDuplicates: true }, ) .select("id"), ); required( await db .from("bug_reports") .update({ state: "pending", screenshot_path: screenshotPath, updated_at: new Date().toISOString(), }) .eq("id", id) .eq("state", "draft") .select("id"), ); } syncBugAfterResponse(id); return respond(await view(await owned(id))); } if (path[2] === "messages") { if (record.state === "done") throw new ServiceError("This bug report is Done", 409); if (record.state === "draft") throw new ServiceError("Submit this report before sending messages", 409); const body = bugMessageSchema.parse(await jsonBody(request)); const existing = await db .from("bug_report_messages") .select("report_id,kind") .eq("report_id", id) .eq("client_id", body.clientId) .maybeSingle(); if (existing.error) required(existing); if ( existing.data && (existing.data.report_id !== id || existing.data.kind !== "user") ) throw new ServiceError("Invalid support message", 400); if (!existing.data) { await limit("support:message:" + hash, 30, 60); required( await db .from("bug_report_messages") .upsert( { client_id: body.clientId, report_id: id, kind: "user", body: body.text, author_name: profile?.display_name ?? record.display_name, }, { onConflict: "report_id,client_id", ignoreDuplicates: true }, ) .select("id"), ); const inserted = required( await db .from("bug_report_messages") .select("report_id,kind") .eq("report_id", id) .eq("client_id", body.clientId) .single(), ); if (inserted.report_id !== id || inserted.kind !== "user") throw new ServiceError("Invalid support message", 400); } syncBugAfterResponse(id); return respond(await view(record)); } if (path[2] === "close") { z.object({}) .strict() .parse(await jsonBody(request)); if (!record.issue_synced) throw new ServiceError( "Wait for support to receive this report before closing it", 409, ); required( await db.rpc("set_bug_report_done", { p_report: id, p_done: true }), ); await scheduleBugCleanup(id); return respond(await view(await owned(id))); } if (path[2] === "retry-cleanup" && record.state === "done") { z.object({}) .strict() .parse(await jsonBody(request)); required( await db .from("bug_reports") .update({ cleanup_lease_until: null }) .eq("id", id) .is("cleanup_run_id", null) .select("id"), ); await scheduleBugCleanup(id); return respond(await view(await owned(id))); } if (path[2] === "claim") { if (!account) throw new ServiceError("Sign in to link this report", 401); required( await db.rpc("claim_bug_report", { p_report: id, p_hash: hash, p_owner: account.id, }), ); return respond(await view(await owned(id))); } return NextResponse.json({ error: "Not found" }, { status: 404 }); }