Files
MineGeo/lib/bug-report-api.ts

305 lines
9.8 KiB
TypeScript

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<unknown>;
limit: (key: string, count: number, seconds: number) => Promise<void>;
};
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<BugReportView> {
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 });
}