Add player bug reports and profile identity features
This commit is contained in:
@@ -70,13 +70,27 @@ export async function accountApi(
|
||||
});
|
||||
if (request.method === "PATCH") {
|
||||
const body = profileUpdateSchema.parse(await context.jsonBody(request));
|
||||
await accountProfile(account);
|
||||
const current = await accountProfile(account);
|
||||
if (body.glowColor !== undefined && !current.name_glow)
|
||||
return json(
|
||||
{ error: "Earn a username glow before choosing its color" },
|
||||
403,
|
||||
);
|
||||
const profile = required(
|
||||
await admin()
|
||||
.from("profiles")
|
||||
.update({ display_name: body.displayName })
|
||||
.update({
|
||||
...(body.displayName !== undefined
|
||||
? { display_name: body.displayName }
|
||||
: {}),
|
||||
...(body.glowColor !== undefined
|
||||
? { glow_color: body.glowColor }
|
||||
: {}),
|
||||
})
|
||||
.eq("id", account.id)
|
||||
.select("id,display_name,role")
|
||||
.select(
|
||||
"id,display_name,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon",
|
||||
)
|
||||
.single(),
|
||||
);
|
||||
return json({ profile });
|
||||
|
||||
@@ -16,6 +16,8 @@ import { ServiceError, checkServiceError } from "./service-error";
|
||||
import { requestOrigin, verifyOrigin } from "./request-origin";
|
||||
import { accountApi } from "./account-api";
|
||||
import { multiplayerApi } from "./multiplayer-api";
|
||||
import { bugReportApi } from "./bug-report-api";
|
||||
import { moderatorUsersApi } from "./moderator-users-api";
|
||||
import { contributionCutoff, reportSchema } from "../shared/moderation";
|
||||
import { siteSettingsSchema } from "../shared/site-settings";
|
||||
import { getSiteSettings } from "./site-settings";
|
||||
@@ -119,6 +121,8 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
return await accountApi(request, path, { jsonBody, limit });
|
||||
if (path[0] === "rooms")
|
||||
return await multiplayerApi(request, path, { jsonBody, limit });
|
||||
if (path[0] === "bug-reports")
|
||||
return await bugReportApi(request, path, { jsonBody, limit });
|
||||
const client = admin(),
|
||||
method = request.method;
|
||||
if (path[0] === "site-settings" && path.length === 1 && method === "GET") {
|
||||
@@ -900,6 +904,8 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
}
|
||||
if (path[0] === "moderation") {
|
||||
const reviewer = await moderator(request);
|
||||
if (path[1] === "users")
|
||||
return await moderatorUsersApi(request, path, reviewer, jsonBody);
|
||||
if (path[1] === "settings" && path.length === 2 && method === "PATCH") {
|
||||
const body = siteSettingsSchema.parse(await jsonBody(request));
|
||||
const enabled = required(
|
||||
|
||||
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 });
|
||||
}
|
||||
@@ -26,6 +26,8 @@ export type MapListing = {
|
||||
image?: string;
|
||||
mapImage?: string;
|
||||
credit?: string;
|
||||
creditGlow?: boolean;
|
||||
creditAppearance?: import("../shared/profile-appearance").ProfileAppearance;
|
||||
};
|
||||
export async function mediaUrl(path: string | null, expires = 3600) {
|
||||
return (await mediaUrls([path], expires))[0];
|
||||
@@ -61,7 +63,9 @@ export async function catalog() {
|
||||
? required(
|
||||
await client
|
||||
.from("profiles")
|
||||
.select("id,display_name")
|
||||
.select(
|
||||
"id,display_name,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
|
||||
)
|
||||
.in("id", [...new Set(maps.map((m) => m.owner_id))]),
|
||||
)
|
||||
: [];
|
||||
@@ -71,6 +75,8 @@ export async function catalog() {
|
||||
return maps.map((m, index) => ({
|
||||
...m,
|
||||
credit: owners.find((p) => p.id === m.owner_id)?.display_name ?? "Explorer",
|
||||
creditGlow: owners.find((p) => p.id === m.owner_id)?.name_glow ?? false,
|
||||
creditAppearance: owners.find((p) => p.id === m.owner_id),
|
||||
image: signed[index * 2] ?? undefined,
|
||||
mapImage: signed[index * 2 + 1] ?? undefined,
|
||||
server: servers.find((s) => s.id === m.server_id),
|
||||
@@ -90,7 +96,9 @@ export async function getMap(id: string) {
|
||||
const [owner, signed] = await Promise.all([
|
||||
admin()
|
||||
.from("profiles")
|
||||
.select("display_name")
|
||||
.select(
|
||||
"display_name,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
|
||||
)
|
||||
.eq("id", map.owner_id)
|
||||
.maybeSingle(),
|
||||
mediaUrls([map.cover_path, map.map_path]),
|
||||
@@ -98,6 +106,8 @@ export async function getMap(id: string) {
|
||||
return {
|
||||
...map,
|
||||
credit: owner.data?.display_name ?? "Explorer",
|
||||
creditGlow: owner.data?.name_glow ?? false,
|
||||
creditAppearance: owner.data ?? undefined,
|
||||
image: signed[0],
|
||||
mapImage: signed[1],
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function leaderboard({
|
||||
limit?: number;
|
||||
} = {}): Promise<LeaderboardData | null> {
|
||||
try {
|
||||
return required(
|
||||
const data = required(
|
||||
await admin().rpc("player_leaderboard", {
|
||||
p_difficulty: difficulty,
|
||||
p_search: search,
|
||||
@@ -25,6 +25,30 @@ export async function leaderboard({
|
||||
p_limit: limit,
|
||||
}),
|
||||
) as unknown as LeaderboardData;
|
||||
const ids = [
|
||||
...new Set(
|
||||
[...data.entries, ...data.leaders].map((entry) => entry.player_id),
|
||||
),
|
||||
];
|
||||
const profiles = ids.length
|
||||
? required(
|
||||
await admin()
|
||||
.from("profiles")
|
||||
.select(
|
||||
"id,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
|
||||
)
|
||||
.in("id", ids),
|
||||
)
|
||||
: [];
|
||||
const addGlow = (entry: LeaderboardData["entries"][number]) => ({
|
||||
...entry,
|
||||
...profiles.find((profile) => profile.id === entry.player_id),
|
||||
});
|
||||
return {
|
||||
...data,
|
||||
entries: data.entries.map(addGlow),
|
||||
leaders: data.leaders.map(addGlow),
|
||||
};
|
||||
} catch (error) {
|
||||
// A leaderboard outage must not take the map catalog or homepage offline.
|
||||
console.error(
|
||||
|
||||
168
lib/linear-support-client.ts
Normal file
168
lib/linear-support-client.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
escapeSupportMarkdown,
|
||||
type BugReportRecord,
|
||||
} from "../shared/bug-reports";
|
||||
|
||||
type Issue = { id: string; identifier: string; team: { id: string } };
|
||||
export class LinearSupportClient {
|
||||
constructor(
|
||||
private key: string,
|
||||
private team: string,
|
||||
private project?: string,
|
||||
private request: typeof fetch = fetch,
|
||||
) {}
|
||||
async graphql<T>(
|
||||
query: string,
|
||||
variables: object,
|
||||
missing = false,
|
||||
): Promise<T | null> {
|
||||
const response = await this.request("https://api.linear.app/graphql", {
|
||||
method: "POST",
|
||||
headers: { Authorization: this.key, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (
|
||||
missing &&
|
||||
response.ok &&
|
||||
result.errors?.length &&
|
||||
result.errors?.every(
|
||||
(error: { message?: string; extensions?: { code?: string } }) =>
|
||||
error.extensions?.code === "ENTITY_NOT_FOUND" ||
|
||||
/^Entity not found/i.test(error.message ?? ""),
|
||||
)
|
||||
)
|
||||
return null;
|
||||
if (!response.ok || result.errors?.length || !result.data)
|
||||
throw new Error("Support delivery is temporarily unavailable");
|
||||
return result.data;
|
||||
}
|
||||
async issue(id: string): Promise<Issue | null> {
|
||||
const data = await this.graphql<{ issue: Issue }>(
|
||||
"query SupportIssue($id: String!) { issue(id: $id) { id identifier team { id } } }",
|
||||
{ id },
|
||||
true,
|
||||
);
|
||||
const issue = data?.issue ?? null;
|
||||
if (issue && issue.team.id !== this.team)
|
||||
throw new Error("Support issue team mismatch");
|
||||
return issue;
|
||||
}
|
||||
async createIssue(report: BugReportRecord): Promise<Issue> {
|
||||
const existing = await this.issue(report.id);
|
||||
if (existing) return existing;
|
||||
const description = [
|
||||
"#user-report-message",
|
||||
`Reporter: ${escapeSupportMarkdown(report.display_name)}`,
|
||||
`Page: ${report.page_path}`,
|
||||
"",
|
||||
escapeSupportMarkdown(report.description),
|
||||
"",
|
||||
report.linear_asset_url
|
||||
? ``
|
||||
: "",
|
||||
"",
|
||||
"Reply to the player with #player-report-send followed by your message.",
|
||||
"Grant or remove the username reward with #player-report-glow grant or #player-report-glow revoke.",
|
||||
`Report reference: ${report.id}`,
|
||||
].join("\n");
|
||||
try {
|
||||
const data = await this.graphql<{
|
||||
issueCreate: { success: boolean; issue: Issue };
|
||||
}>(
|
||||
"mutation SupportIssueCreate($input: IssueCreateInput!) { issueCreate(input:$input) { success issue { id identifier team { id } } } }",
|
||||
{
|
||||
input: {
|
||||
id: report.id,
|
||||
teamId: this.team,
|
||||
...(this.project ? { projectId: this.project } : {}),
|
||||
title: `[Player bug] ${report.title}`,
|
||||
description,
|
||||
priority: 3,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (
|
||||
!data?.issueCreate.success ||
|
||||
data.issueCreate.issue?.id !== report.id
|
||||
)
|
||||
throw new Error("Support issue creation failed");
|
||||
return data.issueCreate.issue;
|
||||
} catch (error) {
|
||||
// A timed-out creation may have succeeded. A stable UUID prevents a second issue.
|
||||
const recovered = await this.issue(report.id);
|
||||
if (recovered) return recovered;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async comment(id: string, reportId: string, name: string, text: string) {
|
||||
const lookup = () =>
|
||||
this.graphql<{ comment: { id: string; issue: { id: string } } }>(
|
||||
"query SupportComment($id:String!) { comment(id:$id) { id issue { id } } }",
|
||||
{ id },
|
||||
true,
|
||||
);
|
||||
const existing = (await lookup())?.comment;
|
||||
if (existing) {
|
||||
if (existing.issue.id !== reportId)
|
||||
throw new Error("Support comment mismatch");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await this.graphql<{
|
||||
commentCreate: { success: boolean; comment: { id: string } };
|
||||
}>(
|
||||
"mutation SupportCommentCreate($input:CommentCreateInput!) { commentCreate(input:$input) { success comment { id } } }",
|
||||
{
|
||||
input: {
|
||||
id,
|
||||
issueId: reportId,
|
||||
body: `#user-report-message\n${escapeSupportMarkdown(name)}\n\n${escapeSupportMarkdown(text)}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!data?.commentCreate.success || data.commentCreate.comment?.id !== id)
|
||||
throw new Error("Support comment creation failed");
|
||||
} catch (error) {
|
||||
const recovered = (await lookup())?.comment;
|
||||
if (!recovered || recovered.issue.id !== reportId) throw error;
|
||||
}
|
||||
}
|
||||
async upload(bytes: Uint8Array, filename: string) {
|
||||
const data = await this.graphql<{
|
||||
fileUpload: {
|
||||
success: boolean;
|
||||
uploadFile: {
|
||||
uploadUrl: string;
|
||||
assetUrl: string;
|
||||
headers: { key: string; value: string }[];
|
||||
};
|
||||
};
|
||||
}>(
|
||||
'mutation SupportFile($size:Int!,$filename:String!){ fileUpload(contentType:"image/webp",filename:$filename,size:$size) { success uploadFile { uploadUrl assetUrl headers { key value } } } }',
|
||||
{ filename, size: bytes.length },
|
||||
);
|
||||
const file = data?.fileUpload.uploadFile;
|
||||
if (
|
||||
!data?.fileUpload.success ||
|
||||
!file ||
|
||||
new URL(file.uploadUrl).protocol !== "https:" ||
|
||||
new URL(file.assetUrl).protocol !== "https:"
|
||||
)
|
||||
throw new Error("Support screenshot upload failed");
|
||||
const headers = new Headers({
|
||||
"Content-Type": "image/webp",
|
||||
"Cache-Control": "private, max-age=31536000",
|
||||
});
|
||||
file.headers.forEach(({ key, value }) => headers.set(key, value));
|
||||
const result = await this.request(file.uploadUrl, {
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: Buffer.from(bytes),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!result.ok) throw new Error("Support screenshot upload failed");
|
||||
return file.assetUrl;
|
||||
}
|
||||
}
|
||||
60
lib/moderator-users-api.ts
Normal file
60
lib/moderator-users-api.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import "server-only";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { admin, required } from "./supabase";
|
||||
import { ServiceError, checkServiceError } from "./service-error";
|
||||
import { prefixUpdateSchema } from "@/shared/profile-appearance";
|
||||
|
||||
export async function moderatorUsersApi(
|
||||
request: NextRequest,
|
||||
path: string[],
|
||||
reviewer: string,
|
||||
jsonBody: (request: NextRequest) => Promise<unknown>,
|
||||
) {
|
||||
const db = admin();
|
||||
const columns =
|
||||
"id,display_name,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon" as const;
|
||||
const respond = (body: unknown) =>
|
||||
NextResponse.json(body, { headers: { "Cache-Control": "no-store" } });
|
||||
if (request.method === "GET" && path.length === 2) {
|
||||
const search = (request.nextUrl.searchParams.get("q") ?? "")
|
||||
.trim()
|
||||
.slice(0, 64);
|
||||
let query = db
|
||||
.from("profiles")
|
||||
.select(columns)
|
||||
.order("display_name")
|
||||
.order("id")
|
||||
.limit(50);
|
||||
if (search) {
|
||||
if (z.string().uuid().safeParse(search).success)
|
||||
query = query.eq("id", search);
|
||||
else
|
||||
query = query.ilike(
|
||||
"display_name",
|
||||
"%" + search.replace(/[\\%_]/g, "\\$&") + "%",
|
||||
);
|
||||
}
|
||||
return respond({ users: required(await query) });
|
||||
}
|
||||
if (request.method === "PATCH" && path.length === 3) {
|
||||
const id = z.string().uuid().parse(path[2]),
|
||||
body = prefixUpdateSchema.parse(await jsonBody(request));
|
||||
const updated = await db.rpc("set_user_prefix", {
|
||||
p_reviewer: reviewer,
|
||||
p_user: id,
|
||||
p_prefix: body.prefix,
|
||||
p_icon: body.icon,
|
||||
});
|
||||
checkServiceError(updated.error);
|
||||
const result = await db
|
||||
.from("profiles")
|
||||
.select(columns)
|
||||
.eq("id", id)
|
||||
.maybeSingle();
|
||||
if (result.error) required(result);
|
||||
if (!result.data) throw new ServiceError("Player not found", 404);
|
||||
return respond({ user: result.data });
|
||||
}
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { createHash, randomBytes } from "node:crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { admin, required, user } from "./supabase";
|
||||
import { accountProfile } from "./profile";
|
||||
import { multiplayerAppearance } from "./multiplayer-appearance";
|
||||
import { mediaUrls } from "./catalog";
|
||||
import { requestOrigin } from "./request-origin";
|
||||
import { ServiceError } from "./service-error";
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
roomGuessSchema,
|
||||
chatMessageSchema,
|
||||
type RoomView,
|
||||
type RoomChat,
|
||||
} from "../shared/multiplayer";
|
||||
import {
|
||||
datasetSchema,
|
||||
@@ -55,6 +58,7 @@ export async function multiplayerApi(
|
||||
const body = createRoomSchema.parse(await jsonBody(request));
|
||||
await limit("multiplayer:create:" + accessHash, 20, 3600);
|
||||
const account = await user();
|
||||
if (account) await accountProfile(account);
|
||||
const id = required(
|
||||
await client.rpc("create_map_room", {
|
||||
p_dataset: body.datasetId,
|
||||
@@ -65,6 +69,16 @@ export async function multiplayerApi(
|
||||
p_owner: account?.id ?? null,
|
||||
}),
|
||||
);
|
||||
if (account)
|
||||
required(
|
||||
await client
|
||||
.from("multiplayer_players")
|
||||
.update({ account_id: account.id })
|
||||
.eq("room_id", id)
|
||||
.eq("access_hash", accessHash)
|
||||
.is("account_id", null)
|
||||
.select("id"),
|
||||
);
|
||||
return respond({ id });
|
||||
}
|
||||
const id = z.string().uuid().parse(path[1]);
|
||||
@@ -81,16 +95,25 @@ export async function multiplayerApi(
|
||||
? chatMessageSchema.parse(await jsonBody(request))
|
||||
: null;
|
||||
if (body) await limit("multiplayer:chat:" + accessHash, 60, 60);
|
||||
return respond(
|
||||
required(
|
||||
await client.rpc("multiplayer_chat", {
|
||||
p_room: id,
|
||||
p_access_hash: accessHash,
|
||||
p_text: body?.text ?? null,
|
||||
p_client_id: body?.clientId ?? null,
|
||||
}),
|
||||
),
|
||||
const chat = required(
|
||||
await client.rpc("multiplayer_chat", {
|
||||
p_room: id,
|
||||
p_access_hash: accessHash,
|
||||
p_text: body?.text ?? null,
|
||||
p_client_id: body?.clientId ?? null,
|
||||
}),
|
||||
) as unknown as RoomChat;
|
||||
const appearances = await multiplayerAppearance(
|
||||
id,
|
||||
chat.messages.map((message) => message.playerId),
|
||||
);
|
||||
return respond({
|
||||
...chat,
|
||||
messages: chat.messages.map((message) => ({
|
||||
...message,
|
||||
...appearances.get(message.playerId),
|
||||
})),
|
||||
});
|
||||
}
|
||||
let payload: Record<string, string | number> = {};
|
||||
if (request.method === "POST" && path.length === 3) {
|
||||
@@ -121,6 +144,32 @@ export async function multiplayerApi(
|
||||
p_payload: payload,
|
||||
}),
|
||||
) as unknown as RoomView;
|
||||
if (action === "join" && room.you) {
|
||||
const account = await user();
|
||||
if (account) {
|
||||
await accountProfile(account);
|
||||
required(
|
||||
await client
|
||||
.from("multiplayer_players")
|
||||
.update({ account_id: account.id })
|
||||
.eq("id", room.you)
|
||||
.eq("room_id", id)
|
||||
.eq("access_hash", accessHash)
|
||||
.is("account_id", null)
|
||||
.select("id"),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (action !== "media") {
|
||||
const appearances = await multiplayerAppearance(
|
||||
id,
|
||||
room.players.map((player) => player.id),
|
||||
);
|
||||
room.players = room.players.map((player) => ({
|
||||
...player,
|
||||
...appearances.get(player.id),
|
||||
}));
|
||||
}
|
||||
if (action !== "media") return respond(room);
|
||||
if (!room.you) throw new ServiceError("Join this room to play", 403);
|
||||
if (["lobby", "closed"].includes(room.status))
|
||||
|
||||
42
lib/multiplayer-appearance.ts
Normal file
42
lib/multiplayer-appearance.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import "server-only";
|
||||
import { admin, required } from "./supabase";
|
||||
import type { ProfileAppearance } from "@/shared/profile-appearance";
|
||||
|
||||
export async function multiplayerAppearance(room: string, ids: string[]) {
|
||||
const db = admin(),
|
||||
result = new Map<string, { name: string; appearance: ProfileAppearance }>();
|
||||
if (!ids.length) return result;
|
||||
const players = required(
|
||||
await db
|
||||
.from("multiplayer_players")
|
||||
.select("id,account_id")
|
||||
.eq("room_id", room)
|
||||
.in("id", [...new Set(ids)]),
|
||||
);
|
||||
const accounts = [
|
||||
...new Set(
|
||||
players.flatMap((player) =>
|
||||
player.account_id ? [player.account_id] : [],
|
||||
),
|
||||
),
|
||||
];
|
||||
if (!accounts.length) return result;
|
||||
const profiles = required(
|
||||
await db
|
||||
.from("profiles")
|
||||
.select(
|
||||
"id,display_name,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
|
||||
)
|
||||
.in("id", accounts),
|
||||
);
|
||||
for (const player of players) {
|
||||
const profile = profiles.find(
|
||||
(profile) => profile.id === player.account_id,
|
||||
);
|
||||
if (profile) {
|
||||
const { id, display_name, ...appearance } = profile;
|
||||
result.set(player.id, { name: display_name, appearance });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -20,7 +20,9 @@ export async function accountProfile(account: User) {
|
||||
return required(
|
||||
await admin()
|
||||
.from("profiles")
|
||||
.select("id,display_name,role")
|
||||
.select(
|
||||
"id,display_name,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon",
|
||||
)
|
||||
.eq("id", account.id)
|
||||
.single(),
|
||||
);
|
||||
|
||||
28
lib/support-images.ts
Normal file
28
lib/support-images.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import sharp from "sharp";
|
||||
import { screenshotLimit } from "../shared/bug-reports";
|
||||
|
||||
export async function supportScreenshot(bytes: Uint8Array) {
|
||||
if (!bytes.length || bytes.length > screenshotLimit)
|
||||
throw new Error("Use a screenshot smaller than 5 MB");
|
||||
const image = sharp(bytes, {
|
||||
limitInputPixels: 24_000_000,
|
||||
failOn: "error",
|
||||
animated: false,
|
||||
});
|
||||
const meta = await image.metadata();
|
||||
if (
|
||||
!["png", "jpeg", "webp"].includes(meta.format ?? "") ||
|
||||
(meta.pages ?? 1) !== 1
|
||||
)
|
||||
throw new Error("Choose a PNG, JPG, or WebP screenshot");
|
||||
return image
|
||||
.rotate()
|
||||
.resize({
|
||||
width: 2400,
|
||||
height: 2400,
|
||||
fit: "inside",
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.webp({ quality: 85 })
|
||||
.toBuffer();
|
||||
}
|
||||
22
lib/support-signature.ts
Normal file
22
lib/support-signature.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
export function verifySupportWebhook(
|
||||
raw: string,
|
||||
signature: string | null,
|
||||
secret: string,
|
||||
now = Date.now(),
|
||||
) {
|
||||
if (!secret || !signature || !/^[a-f0-9]{64}$/i.test(signature)) return false;
|
||||
const expected = createHmac("sha256", secret).update(raw).digest();
|
||||
if (!timingSafeEqual(expected, Buffer.from(signature, "hex"))) return false;
|
||||
try {
|
||||
const timestamp = JSON.parse(raw).webhookTimestamp;
|
||||
return (
|
||||
typeof timestamp === "number" &&
|
||||
Number.isFinite(timestamp) &&
|
||||
Math.abs(now - timestamp) <= 60_000
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
123
lib/support-sync.ts
Normal file
123
lib/support-sync.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import "server-only";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { after } from "next/server";
|
||||
import { admin, required } from "./supabase";
|
||||
import { LinearSupportClient } from "./linear-support-client";
|
||||
|
||||
export function supportReady() {
|
||||
return !!(
|
||||
process.env.LINEAR_API_KEY &&
|
||||
process.env.LINEAR_TEAM_ID &&
|
||||
process.env.LINEAR_WEBHOOK_SECRET
|
||||
);
|
||||
}
|
||||
export async function syncBugReport(id: string) {
|
||||
if (!supportReady()) return;
|
||||
const db = admin(),
|
||||
worker = randomUUID();
|
||||
if (
|
||||
!required(
|
||||
await db.rpc("claim_bug_sync", { p_report: id, p_worker: worker }),
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const linear = new LinearSupportClient(
|
||||
process.env.LINEAR_API_KEY!,
|
||||
process.env.LINEAR_TEAM_ID!,
|
||||
process.env.LINEAR_PROJECT_ID,
|
||||
);
|
||||
const report = required(
|
||||
await db.from("bug_reports").select("*").eq("id", id).single(),
|
||||
);
|
||||
if (!report.issue_synced) {
|
||||
if (report.screenshot_path && !report.linear_asset_url) {
|
||||
const image = required(
|
||||
await db.storage
|
||||
.from("minegeo-support")
|
||||
.download(report.screenshot_path),
|
||||
);
|
||||
report.linear_asset_url = await linear.upload(
|
||||
new Uint8Array(await image.arrayBuffer()),
|
||||
`bug-${id}.webp`,
|
||||
);
|
||||
required(
|
||||
await db
|
||||
.from("bug_reports")
|
||||
.update({ linear_asset_url: report.linear_asset_url })
|
||||
.eq("id", id)
|
||||
.eq("lease_owner", worker)
|
||||
.select("id")
|
||||
.single(),
|
||||
);
|
||||
}
|
||||
const issue = await linear.createIssue(report);
|
||||
required(
|
||||
await db
|
||||
.from("bug_reports")
|
||||
.update({
|
||||
issue_synced: true,
|
||||
linear_identifier: issue.identifier,
|
||||
state: "open",
|
||||
})
|
||||
.eq("id", id)
|
||||
.eq("lease_owner", worker)
|
||||
.select("id")
|
||||
.single(),
|
||||
);
|
||||
}
|
||||
required(
|
||||
await db
|
||||
.from("bug_report_messages")
|
||||
.update({ delivered: true })
|
||||
.eq("id", id)
|
||||
.select("id"),
|
||||
);
|
||||
const messages = required(
|
||||
await db
|
||||
.from("bug_report_messages")
|
||||
.select("*")
|
||||
.eq("report_id", id)
|
||||
.eq("kind", "user")
|
||||
.eq("delivered", false)
|
||||
.neq("id", id)
|
||||
.order("created_at")
|
||||
.limit(50),
|
||||
);
|
||||
for (const message of messages) {
|
||||
await linear.comment(message.id, id, message.author_name, message.body);
|
||||
required(
|
||||
await db
|
||||
.from("bug_report_messages")
|
||||
.update({ delivered: true })
|
||||
.eq("id", message.id)
|
||||
.select("id"),
|
||||
);
|
||||
}
|
||||
required(
|
||||
await db
|
||||
.from("bug_reports")
|
||||
.update({ lease_owner: null, lease_until: null, retry_at: null })
|
||||
.eq("id", id)
|
||||
.eq("lease_owner", worker)
|
||||
.select("id"),
|
||||
);
|
||||
} catch {
|
||||
console.error("Bug report delivery paused; the saved report will retry.");
|
||||
required(
|
||||
await db
|
||||
.from("bug_reports")
|
||||
.update({
|
||||
lease_owner: null,
|
||||
lease_until: null,
|
||||
retry_at: new Date(Date.now() + 60_000).toISOString(),
|
||||
})
|
||||
.eq("id", id)
|
||||
.eq("lease_owner", worker)
|
||||
.select("id"),
|
||||
);
|
||||
}
|
||||
}
|
||||
export function syncBugAfterResponse(id: string) {
|
||||
after(() => syncBugReport(id));
|
||||
}
|
||||
Reference in New Issue
Block a user