import "server-only"; import { createHash, randomBytes, randomUUID } from "node:crypto"; import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; 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"; 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 record = required( await db.from("bug_reports").select("*").eq("id", id).maybeSingle(), ); 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, 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), ); 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") { if (record.state !== "draft") syncBugAfterResponse(id); return respond(await view(record)); } 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 === "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] === "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 }); }