360 lines
12 KiB
TypeScript
360 lines
12 KiB
TypeScript
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<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 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<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,
|
|
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 });
|
|
}
|