Add player bug reports and profile identity features
This commit is contained in:
304
lib/bug-report-api.ts
Normal file
304
lib/bug-report-api.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user