Add player bug reports and profile identity features

This commit is contained in:
2026-10-08 18:27:20 +05:00
parent 36e8d11eb4
commit 0b08672f4a
51 changed files with 3938 additions and 45 deletions

View File

@@ -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 });

View File

@@ -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
View 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 });
}

View File

@@ -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],
};

View File

@@ -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(

View 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
? `![Player screenshot](${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;
}
}

View 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 });
}

View File

@@ -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))

View 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;
}

View File

@@ -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
View 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
View 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
View 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));
}