Add player bug reports and profile identity features
This commit is contained in:
@@ -20,3 +20,10 @@ MINEGEO_RENDER_IDLE_SECONDS=600
|
|||||||
# Allow Chromium to use the GPU; software forces SwiftShader for headless hosts.
|
# Allow Chromium to use the GPU; software forces SwiftShader for headless hosts.
|
||||||
MINEGEO_RENDER_BACKEND=auto
|
MINEGEO_RENDER_BACKEND=auto
|
||||||
MINEGEO_MAX_UPLOAD_BYTES=268435456
|
MINEGEO_MAX_UPLOAD_BYTES=268435456
|
||||||
|
|
||||||
|
# Player bug reports. Keep secrets server-only; configure Production on Vercel.
|
||||||
|
LINEAR_API_KEY=
|
||||||
|
LINEAR_WEBHOOK_SECRET=
|
||||||
|
LINEAR_TEAM_ID=
|
||||||
|
# Optional UUID or project identifier (MineGeo: P-DUN-3).
|
||||||
|
LINEAR_PROJECT_ID=
|
||||||
|
|||||||
90
app/api/support/linear/route.ts
Normal file
90
app/api/support/linear/route.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { admin, required } from "@/lib/supabase";
|
||||||
|
import { verifySupportWebhook } from "@/lib/support-signature";
|
||||||
|
import { supportCommand } from "@/shared/bug-reports";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const secret = process.env.LINEAR_WEBHOOK_SECRET;
|
||||||
|
if (!secret)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Support integration is not configured" },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
const reader = request.body?.getReader();
|
||||||
|
if (!reader) return new NextResponse(null, { status: 400 });
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let size = 0;
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
size += value.length;
|
||||||
|
if (size > 256 * 1024) {
|
||||||
|
await reader.cancel();
|
||||||
|
return new NextResponse(null, { status: 413 });
|
||||||
|
}
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
const raw = Buffer.concat(chunks).toString("utf8");
|
||||||
|
if (
|
||||||
|
!verifySupportWebhook(raw, request.headers.get("linear-signature"), secret)
|
||||||
|
)
|
||||||
|
return new NextResponse(null, { status: 401 });
|
||||||
|
let decoded: unknown;
|
||||||
|
try {
|
||||||
|
decoded = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return new NextResponse(null, { status: 400 });
|
||||||
|
}
|
||||||
|
const payload = z
|
||||||
|
.object({
|
||||||
|
action: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
actor: z
|
||||||
|
.object({
|
||||||
|
id: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
})
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
|
data: z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
issueId: z.string().uuid().optional(),
|
||||||
|
body: z.string().optional(),
|
||||||
|
userId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.safeParse(decoded);
|
||||||
|
if (!payload.success) return new NextResponse(null, { status: 400 });
|
||||||
|
const event = payload.data;
|
||||||
|
// Only explicit new staff comments are public. Internal comments and user echoes stay private.
|
||||||
|
if (
|
||||||
|
event.action !== "create" ||
|
||||||
|
event.type !== "Comment" ||
|
||||||
|
event.actor?.type.toLowerCase() !== "user" ||
|
||||||
|
event.data.userId !== event.actor.id ||
|
||||||
|
!event.data.issueId
|
||||||
|
)
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
const command = supportCommand(event.data.body ?? "");
|
||||||
|
if (!command) return NextResponse.json({ ok: true });
|
||||||
|
try {
|
||||||
|
required(
|
||||||
|
await admin().rpc("receive_bug_reply", {
|
||||||
|
p_report: event.data.issueId,
|
||||||
|
p_external: event.data.id,
|
||||||
|
p_author: event.actor.name ?? "Support",
|
||||||
|
p_body: command.kind === "reply" ? command.text : "",
|
||||||
|
p_glow: command.kind === "glow" ? command.enabled : null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Support delivery failed" },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { Dashboard } from "@/components/Dashboard";
|
|||||||
import { admin, user, required } from "@/lib/supabase";
|
import { admin, user, required } from "@/lib/supabase";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { accountProfile } from "@/lib/profile";
|
import { accountProfile } from "@/lib/profile";
|
||||||
|
import { Username } from "@/components/Username";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const { t, formatDate } = await getI18n();
|
const { t, formatDate } = await getI18n();
|
||||||
@@ -33,7 +34,12 @@ export default async function DashboardPage() {
|
|||||||
<span className="eyebrow">{t("THE CARTOGRAPHER’S DESK")}</span>
|
<span className="eyebrow">{t("THE CARTOGRAPHER’S DESK")}</span>
|
||||||
<h1>{t("My expedition.")}</h1>
|
<h1>{t("My expedition.")}</h1>
|
||||||
<p className="lead">
|
<p className="lead">
|
||||||
{t("Welcome back,")} {profile?.display_name}.
|
{t("Welcome back,")}{" "}
|
||||||
|
<Username
|
||||||
|
name={profile?.display_name ?? "Explorer"}
|
||||||
|
appearance={profile ?? undefined}
|
||||||
|
/>
|
||||||
|
.
|
||||||
</p>
|
</p>
|
||||||
<Dashboard />
|
<Dashboard />
|
||||||
{history.length > 0 && (
|
{history.length > 0 && (
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { getI18n } from "@/lib/i18n";
|
|||||||
import "./polish.css";
|
import "./polish.css";
|
||||||
import "./mobile-game.css";
|
import "./mobile-game.css";
|
||||||
import "./multiplayer.css";
|
import "./multiplayer.css";
|
||||||
|
import "./support.css";
|
||||||
import { SiteSettingsProvider } from "@/components/SiteSettingsProvider";
|
import { SiteSettingsProvider } from "@/components/SiteSettingsProvider";
|
||||||
import { initialSiteSettings } from "@/lib/site-settings";
|
import { initialSiteSettings } from "@/lib/site-settings";
|
||||||
import { user } from "@/lib/supabase";
|
import { user } from "@/lib/supabase";
|
||||||
|
|||||||
@@ -286,7 +286,7 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.room-chat-log strong,
|
.room-chat-log strong,
|
||||||
.room-chat-log span {
|
.room-chat-log p > span {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.room-chat-log strong {
|
.room-chat-log strong {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getI18n } from "@/lib/i18n";
|
import { getI18n } from "@/lib/i18n";
|
||||||
|
import { Username } from "@/components/Username";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Header, Footer } from "@/components/Shell";
|
import { Header, Footer } from "@/components/Shell";
|
||||||
@@ -31,7 +32,8 @@ export default async function ServerPage({
|
|||||||
<h1>{map.name}</h1>
|
<h1>{map.name}</h1>
|
||||||
<p className="lead">{map.description}</p>
|
<p className="lead">{map.description}</p>
|
||||||
<p className="map-credit">
|
<p className="map-credit">
|
||||||
{t("Created by")} {map.credit}
|
{t("Created by")}{" "}
|
||||||
|
<Username name={map.credit} appearance={map.creditAppearance} />
|
||||||
</p>
|
</p>
|
||||||
<ReportContent datasetId={id} />
|
<ReportContent datasetId={id} />
|
||||||
{account?.id === map.owner_id && <ExportMap id={id} />}
|
{account?.id === map.owner_id && <ExportMap id={id} />}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Header, Footer } from "@/components/Shell";
|
|||||||
import { AccountSettings } from "@/components/AccountSettings";
|
import { AccountSettings } from "@/components/AccountSettings";
|
||||||
import { user } from "@/lib/supabase";
|
import { user } from "@/lib/supabase";
|
||||||
import { accountProfile } from "@/lib/profile";
|
import { accountProfile } from "@/lib/profile";
|
||||||
|
import { Username } from "@/components/Username";
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const { t } = await getI18n();
|
const { t } = await getI18n();
|
||||||
return { title: t("Account settings") };
|
return { title: t("Account settings") };
|
||||||
@@ -32,12 +33,14 @@ export default async function Settings({
|
|||||||
<span className="eyebrow">{t("YOUR CARTOGRAPHER ACCOUNT")}</span>
|
<span className="eyebrow">{t("YOUR CARTOGRAPHER ACCOUNT")}</span>
|
||||||
<h1>{t("Account settings.")}</h1>
|
<h1>{t("Account settings.")}</h1>
|
||||||
<p className="lead">
|
<p className="lead">
|
||||||
|
<Username name={profile.display_name} appearance={profile} /> ·{" "}
|
||||||
{t("Your name, your sign-in, your next expedition.")}
|
{t("Your name, your sign-in, your next expedition.")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<AccountSettings
|
<AccountSettings
|
||||||
email={account.email ?? ""}
|
email={account.email ?? ""}
|
||||||
displayName={profile.display_name}
|
displayName={profile.display_name}
|
||||||
|
appearance={profile}
|
||||||
passwordUpdated={query.password === "updated"}
|
passwordUpdated={query.password === "updated"}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
264
app/support.css
Normal file
264
app/support.css
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.header-report {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 7px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 44px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.support-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 2fr) minmax(220px, 1fr);
|
||||||
|
gap: 50px;
|
||||||
|
}
|
||||||
|
.support-layout h1,
|
||||||
|
.support-conversation h1 {
|
||||||
|
margin-top: 14px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.support-form form,
|
||||||
|
.support-conversation form {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.support-form label,
|
||||||
|
.support-conversation label {
|
||||||
|
font-weight: 650;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.support-form input,
|
||||||
|
.support-form textarea,
|
||||||
|
.support-conversation textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 12px;
|
||||||
|
font: 16px var(--font-body, sans-serif);
|
||||||
|
}
|
||||||
|
.support-form textarea,
|
||||||
|
.support-conversation textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.support-form small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.support-form form .button,
|
||||||
|
.support-conversation form .button {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
.support-inbox {
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
padding-left: 28px;
|
||||||
|
}
|
||||||
|
.support-inbox ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.support-inbox li {
|
||||||
|
padding: 16px 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.support-inbox strong,
|
||||||
|
.support-inbox small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.support-inbox small {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.support-conversation {
|
||||||
|
max-width: 850px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.support-conversation > .eyebrow {
|
||||||
|
display: block;
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
.support-screenshot {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 400px;
|
||||||
|
object-fit: contain;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
.support-messages {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
.support-message {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 18px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.support-message-staff {
|
||||||
|
border-color: var(--accent, #8dce66);
|
||||||
|
}
|
||||||
|
.support-message-system {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
.support-message header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.support-message small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.support-message p {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
.support-reward {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #dac880;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.support-reward small {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
.username-glow {
|
||||||
|
color: #ffe6a1;
|
||||||
|
text-shadow:
|
||||||
|
0 0 8px #ffca6090,
|
||||||
|
0 0 18px #ffca6060;
|
||||||
|
}
|
||||||
|
.username {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
max-width: 100%;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
.username > span {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.username-prefix {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font:
|
||||||
|
600 11px/1.4 Arial,
|
||||||
|
sans-serif;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 5px;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
.username-prefix svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.username-moderator {
|
||||||
|
color: #91d3ff;
|
||||||
|
}
|
||||||
|
.username-bug-finder {
|
||||||
|
color: #ffe6a1;
|
||||||
|
}
|
||||||
|
.username-custom-prefix {
|
||||||
|
color: #cdb8ff;
|
||||||
|
}
|
||||||
|
.username-preview {
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
.glow-settings input[type="color"] {
|
||||||
|
width: 90px;
|
||||||
|
height: 48px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.prefix-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.prefix-search .field {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
.prefix-player-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.prefix-player-list button {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.prefix-player-list button[aria-pressed="true"] {
|
||||||
|
border-color: #91d3ff;
|
||||||
|
}
|
||||||
|
.prefix-player-list small {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 5px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.prefix-editor {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.header-report span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.header-report {
|
||||||
|
width: 44px;
|
||||||
|
}
|
||||||
|
.header-actions {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.support-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
.support-inbox {
|
||||||
|
border-left: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
.header-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/support/[id]/page.tsx
Normal file
22
app/support/[id]/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Header, Footer } from "@/components/Shell";
|
||||||
|
import { SupportConversation } from "@/components/Support";
|
||||||
|
export const metadata = { robots: { index: false, follow: false } };
|
||||||
|
export default async function Report({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
if (!z.string().uuid().safeParse(id).success) notFound();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<main className="section page-content">
|
||||||
|
<SupportConversation key={id} id={id} />
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
app/support/page.tsx
Normal file
27
app/support/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Header, Footer } from "@/components/Shell";
|
||||||
|
import { SupportInbox } from "@/components/Support";
|
||||||
|
import { getI18n } from "@/lib/i18n";
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const { t } = await getI18n();
|
||||||
|
return { title: t("Report a bug") };
|
||||||
|
}
|
||||||
|
export default async function Support({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ from?: string }>;
|
||||||
|
}) {
|
||||||
|
const query = await searchParams;
|
||||||
|
const pagePath =
|
||||||
|
query.from && /^\/(?!\/)[a-zA-Z0-9/_-]{0,299}$/.test(query.from)
|
||||||
|
? query.from
|
||||||
|
: "/";
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<main className="section page-content">
|
||||||
|
<SupportInbox pagePath={pagePath} />
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,13 +7,20 @@ import { UserRound, LockKeyhole, Mail } from "lucide-react";
|
|||||||
import { api } from "./StartGame";
|
import { api } from "./StartGame";
|
||||||
import { PasswordForm } from "./PasswordForm";
|
import { PasswordForm } from "./PasswordForm";
|
||||||
import { displayNameSchema } from "@/shared/account";
|
import { displayNameSchema } from "@/shared/account";
|
||||||
|
import {
|
||||||
|
defaultGlowColor,
|
||||||
|
type ProfileAppearance,
|
||||||
|
} from "@/shared/profile-appearance";
|
||||||
|
import { Username } from "./Username";
|
||||||
export function AccountSettings({
|
export function AccountSettings({
|
||||||
email,
|
email,
|
||||||
displayName,
|
displayName,
|
||||||
|
appearance,
|
||||||
passwordUpdated = false,
|
passwordUpdated = false,
|
||||||
}: {
|
}: {
|
||||||
email: string;
|
email: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
appearance: ProfileAppearance;
|
||||||
passwordUpdated?: boolean;
|
passwordUpdated?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -25,6 +32,25 @@ export function AccountSettings({
|
|||||||
[recoveryError, setRecoveryError] = useState(""),
|
[recoveryError, setRecoveryError] = useState(""),
|
||||||
[sending, setSending] = useState(false);
|
[sending, setSending] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [color, setColor] = useState(appearance.glow_color ?? defaultGlowColor);
|
||||||
|
const [colorBusy, setColorBusy] = useState(false),
|
||||||
|
[colorError, setColorError] = useState(""),
|
||||||
|
[colorSaved, setColorSaved] = useState(false);
|
||||||
|
async function saveColor(event: React.FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setColorBusy(true);
|
||||||
|
setColorError("");
|
||||||
|
setColorSaved(false);
|
||||||
|
try {
|
||||||
|
await api("profile", { glowColor: color }, "PATCH");
|
||||||
|
setColorSaved(true);
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
setColorError((error as Error).message);
|
||||||
|
} finally {
|
||||||
|
setColorBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
async function save(event: React.FormEvent) {
|
async function save(event: React.FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError("");
|
setError("");
|
||||||
@@ -119,6 +145,49 @@ export function AccountSettings({
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
<section className="settings-card glow-settings">
|
||||||
|
<h2>{t("Username glow")}</h2>
|
||||||
|
<p className="muted">
|
||||||
|
{appearance.name_glow
|
||||||
|
? t("Choose the color of your glow.")
|
||||||
|
: t("Support can award a glow for a confirmed bug report.")}
|
||||||
|
</p>
|
||||||
|
<div className="username-preview">
|
||||||
|
<Username
|
||||||
|
name={name}
|
||||||
|
appearance={{ ...appearance, glow_color: color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{appearance.name_glow && (
|
||||||
|
<form onSubmit={saveColor}>
|
||||||
|
<label className="field">
|
||||||
|
{t("Glow color")}
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={color}
|
||||||
|
onChange={(event) => {
|
||||||
|
setColor(event.target.value);
|
||||||
|
setColorSaved(false);
|
||||||
|
}}
|
||||||
|
disabled={colorBusy}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{colorError && (
|
||||||
|
<p role="alert" className="error-message">
|
||||||
|
{t(colorError)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{colorSaved && (
|
||||||
|
<p role="status" className="success-message">
|
||||||
|
{t("Glow color saved.")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button className="button primary" disabled={colorBusy}>
|
||||||
|
{colorBusy ? t("Saving…") : t("Save glow color")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
<section className="settings-card">
|
<section className="settings-card">
|
||||||
<div className="settings-heading">
|
<div className="settings-heading">
|
||||||
<LockKeyhole size={22} />
|
<LockKeyhole size={22} />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useI18n } from "@/components/I18nProvider";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import { PixelTrophy, PlayerAvatar } from "./PixelArt";
|
import { PixelTrophy, PlayerAvatar } from "./PixelArt";
|
||||||
|
import { Username } from "./Username";
|
||||||
import {
|
import {
|
||||||
type LeaderboardData,
|
type LeaderboardData,
|
||||||
type LeaderboardEntry,
|
type LeaderboardEntry,
|
||||||
@@ -105,7 +106,7 @@ export function LeaderboardTable({
|
|||||||
<div className="leaderboard-player">
|
<div className="leaderboard-player">
|
||||||
<PlayerAvatar id={entry.player_id} />
|
<PlayerAvatar id={entry.player_id} />
|
||||||
<span className="player-name">
|
<span className="player-name">
|
||||||
{entry.display_name}
|
<Username name={entry.display_name} appearance={entry} />
|
||||||
{entry.player_id === playerId && (
|
{entry.player_id === playerId && (
|
||||||
<span className="you-label">{t("You")}</span>
|
<span className="you-label">{t("You")}</span>
|
||||||
)}
|
)}
|
||||||
@@ -152,7 +153,9 @@ export function WinnersPodium({ leaders }: { leaders: LeaderboardEntry[] }) {
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
<PlayerAvatar id={entry.player_id} />
|
<PlayerAvatar id={entry.player_id} />
|
||||||
<h2>{entry.display_name}</h2>
|
<h2>
|
||||||
|
<Username name={entry.display_name} appearance={entry} />
|
||||||
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
{formatNumber(entry.best_score)} <span>/ 25,000</span>
|
{formatNumber(entry.best_score)} <span>/ 25,000</span>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useI18n } from "@/components/I18nProvider";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowUpRight, MapPin } from "lucide-react";
|
import { ArrowUpRight, MapPin } from "lucide-react";
|
||||||
|
import { Username } from "./Username";
|
||||||
export type CardMap = {
|
export type CardMap = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -11,6 +12,8 @@ export type CardMap = {
|
|||||||
panorama_count: number;
|
panorama_count: number;
|
||||||
image?: string | null;
|
image?: string | null;
|
||||||
credit?: string;
|
credit?: string;
|
||||||
|
creditGlow?: boolean;
|
||||||
|
creditAppearance?: import("@/shared/profile-appearance").ProfileAppearance;
|
||||||
server?: { name: string } | undefined;
|
server?: { name: string } | undefined;
|
||||||
bounds: { minX: number; minZ: number; maxX: number; maxZ: number };
|
bounds: { minX: number; minZ: number; maxX: number; maxZ: number };
|
||||||
};
|
};
|
||||||
@@ -51,7 +54,12 @@ export function MapCard({
|
|||||||
<p>{map.description}</p>
|
<p>{map.description}</p>
|
||||||
{map.credit && (
|
{map.credit && (
|
||||||
<p className="map-credit">
|
<p className="map-credit">
|
||||||
{t("By")} {map.credit}
|
{t("By")}{" "}
|
||||||
|
<Username
|
||||||
|
name={map.credit}
|
||||||
|
appearance={map.creditAppearance}
|
||||||
|
glow={map.creditGlow}
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="card-meta">
|
<div className="card-meta">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { PanoramaPreview } from "./PanoramaPreview";
|
|||||||
import { reportCategories } from "@/shared/moderation";
|
import { reportCategories } from "@/shared/moderation";
|
||||||
import type { Appeal } from "./MapAppeal";
|
import type { Appeal } from "./MapAppeal";
|
||||||
import { ModeratorSettings } from "./ModeratorSettings";
|
import { ModeratorSettings } from "./ModeratorSettings";
|
||||||
|
import { ModeratorPrefixes } from "./ModeratorPrefixes";
|
||||||
type ReviewMap = {
|
type ReviewMap = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -114,6 +115,7 @@ export function Moderation() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{authorized && <ModeratorSettings />}
|
{authorized && <ModeratorSettings />}
|
||||||
|
{authorized && <ModeratorPrefixes />}
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="error-message">
|
<p role="alert" className="error-message">
|
||||||
{t(error)}
|
{t(error)}
|
||||||
|
|||||||
218
components/ModeratorPrefixes.tsx
Normal file
218
components/ModeratorPrefixes.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Tags } from "lucide-react";
|
||||||
|
import { useI18n } from "./I18nProvider";
|
||||||
|
import { api } from "@/lib/client-api";
|
||||||
|
import {
|
||||||
|
prefixIcons,
|
||||||
|
prefixUpdateSchema,
|
||||||
|
type BadgeUser,
|
||||||
|
type PrefixIcon,
|
||||||
|
} from "@/shared/profile-appearance";
|
||||||
|
import { Username } from "./Username";
|
||||||
|
|
||||||
|
export function ModeratorPrefixes() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [search, setSearch] = useState(""),
|
||||||
|
[users, setUsers] = useState<BadgeUser[]>([]),
|
||||||
|
[selected, setSelected] = useState<BadgeUser | null>(null);
|
||||||
|
const [prefix, setPrefix] = useState(""),
|
||||||
|
[icon, setIcon] = useState<PrefixIcon>("star"),
|
||||||
|
[busy, setBusy] = useState(false),
|
||||||
|
[error, setError] = useState(""),
|
||||||
|
[saved, setSaved] = useState(false),
|
||||||
|
[searched, setSearched] = useState(false);
|
||||||
|
async function find(event: React.FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
setSaved(false);
|
||||||
|
setSelected(null);
|
||||||
|
try {
|
||||||
|
const result = await api<{ users: BadgeUser[] }>(
|
||||||
|
"moderation/users?q=" + encodeURIComponent(search),
|
||||||
|
);
|
||||||
|
setUsers(result.users);
|
||||||
|
setSearched(true);
|
||||||
|
} catch (failure) {
|
||||||
|
setError((failure as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function save(remove = false) {
|
||||||
|
if (!selected || busy) return;
|
||||||
|
const body = prefixUpdateSchema.safeParse({
|
||||||
|
prefix: remove ? null : prefix,
|
||||||
|
icon,
|
||||||
|
});
|
||||||
|
if (!body.success) {
|
||||||
|
setError(
|
||||||
|
"Use a prefix of 1–24 characters without hidden controls or markup",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
setSaved(false);
|
||||||
|
try {
|
||||||
|
const result = await api<{ user: BadgeUser }>(
|
||||||
|
"moderation/users/" + selected.id,
|
||||||
|
body.data,
|
||||||
|
"PATCH",
|
||||||
|
);
|
||||||
|
setSelected(result.user);
|
||||||
|
setPrefix(result.user.custom_prefix ?? "");
|
||||||
|
setUsers((users) =>
|
||||||
|
users.map((user) => (user.id === result.user.id ? result.user : user)),
|
||||||
|
);
|
||||||
|
setSaved(true);
|
||||||
|
} catch (failure) {
|
||||||
|
setError((failure as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const iconLabels = {
|
||||||
|
star: t("Star"),
|
||||||
|
crown: t("Crown"),
|
||||||
|
heart: t("Heart"),
|
||||||
|
flame: t("Flame"),
|
||||||
|
gem: t("Gem"),
|
||||||
|
trophy: t("Trophy"),
|
||||||
|
sparkles: t("Sparkles"),
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="tool-card moderator-prefixes"
|
||||||
|
aria-labelledby="prefix-heading"
|
||||||
|
>
|
||||||
|
<div className="settings-heading">
|
||||||
|
<Tags size={24} aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<h2 id="prefix-heading">{t("Player prefixes")}</h2>
|
||||||
|
<p>{t("Find a player and assign a custom prefix with an icon.")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={find} className="prefix-search">
|
||||||
|
<label className="field">
|
||||||
|
{t("Player name or account ID")}
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
maxLength={64}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button className="button" disabled={busy}>
|
||||||
|
{t("Find players")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{searched && users.length === 0 && (
|
||||||
|
<p className="muted">{t("No players found.")}</p>
|
||||||
|
)}
|
||||||
|
{users.length > 0 && (
|
||||||
|
<ul className="prefix-player-list">
|
||||||
|
{users.map((user) => (
|
||||||
|
<li key={user.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
aria-pressed={selected?.id === user.id}
|
||||||
|
onClick={() => {
|
||||||
|
setSelected(user);
|
||||||
|
setPrefix(user.custom_prefix ?? "");
|
||||||
|
setIcon(
|
||||||
|
prefixIcons.includes(user.custom_prefix_icon as PrefixIcon)
|
||||||
|
? (user.custom_prefix_icon as PrefixIcon)
|
||||||
|
: "star",
|
||||||
|
);
|
||||||
|
setSaved(false);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Username name={user.display_name} appearance={user} />
|
||||||
|
<small>{user.id}</small>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{selected && (
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void save();
|
||||||
|
}}
|
||||||
|
className="prefix-editor"
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
{t("Custom prefix")}
|
||||||
|
<input
|
||||||
|
value={prefix}
|
||||||
|
onChange={(event) => {
|
||||||
|
setPrefix(event.target.value);
|
||||||
|
setSaved(false);
|
||||||
|
}}
|
||||||
|
minLength={1}
|
||||||
|
maxLength={24}
|
||||||
|
required
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="custom-prefix-icon">{t("Prefix icon")}</label>
|
||||||
|
<select
|
||||||
|
id="custom-prefix-icon"
|
||||||
|
value={icon}
|
||||||
|
onChange={(event) => {
|
||||||
|
setIcon(event.target.value as PrefixIcon);
|
||||||
|
setSaved(false);
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{prefixIcons.map((value) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{iconLabels[value]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="username-preview">
|
||||||
|
<Username
|
||||||
|
name={selected.display_name}
|
||||||
|
appearance={{
|
||||||
|
...selected,
|
||||||
|
custom_prefix: prefix,
|
||||||
|
custom_prefix_icon: icon,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="inline-actions">
|
||||||
|
<button className="button primary" disabled={busy}>
|
||||||
|
{t("Save prefix")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button"
|
||||||
|
disabled={busy || !selected.custom_prefix}
|
||||||
|
onClick={() => void save(true)}
|
||||||
|
>
|
||||||
|
{t("Remove prefix")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="error-message">
|
||||||
|
{t(error)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{saved && (
|
||||||
|
<p role="status" className="success-message">
|
||||||
|
{t("Player prefix saved.")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { Username } from "./Username";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -139,7 +140,7 @@ export function MultiplayerGame({
|
|||||||
{room.players.map((player) => (
|
{room.players.map((player) => (
|
||||||
<li key={player.id}>
|
<li key={player.id}>
|
||||||
<span>
|
<span>
|
||||||
{player.name}
|
<Username name={player.name} appearance={player.appearance} />
|
||||||
{player.id === room.you && <small> {t("(you)")}</small>}
|
{player.id === room.you && <small> {t("(you)")}</small>}
|
||||||
<small>
|
<small>
|
||||||
{player.left
|
{player.left
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useI18n } from "./I18nProvider";
|
|||||||
import { api } from "@/lib/client-api";
|
import { api } from "@/lib/client-api";
|
||||||
import { roundClock } from "@/shared/round-clock";
|
import { roundClock } from "@/shared/round-clock";
|
||||||
import type { RoomView } from "@/shared/multiplayer";
|
import type { RoomView } from "@/shared/multiplayer";
|
||||||
|
import { Username } from "./Username";
|
||||||
|
|
||||||
export function MultiplayerRoom({ id }: { id: string }) {
|
export function MultiplayerRoom({ id }: { id: string }) {
|
||||||
const { t, formatNumber } = useI18n();
|
const { t, formatNumber } = useI18n();
|
||||||
@@ -247,7 +248,10 @@ export function MultiplayerRoom({ id }: { id: string }) {
|
|||||||
{player.id === room.hostId && (
|
{player.id === room.hostId && (
|
||||||
<Crown size={17} aria-label={t("Room owner")} />
|
<Crown size={17} aria-label={t("Room owner")} />
|
||||||
)}{" "}
|
)}{" "}
|
||||||
{player.name}{" "}
|
<Username
|
||||||
|
name={player.name}
|
||||||
|
appearance={player.appearance}
|
||||||
|
/>{" "}
|
||||||
{player.id === room.you && <small>{t("(you)")}</small>}
|
{player.id === room.you && <small>{t("(you)")}</small>}
|
||||||
</span>
|
</span>
|
||||||
{owner && player.id !== room.you && (
|
{owner && player.id !== room.you && (
|
||||||
@@ -304,7 +308,16 @@ export function MultiplayerRoom({ id }: { id: string }) {
|
|||||||
<>
|
<>
|
||||||
<p className="lead">
|
<p className="lead">
|
||||||
<Trophy size={24} aria-hidden="true" />{" "}
|
<Trophy size={24} aria-hidden="true" />{" "}
|
||||||
{winners.map((player) => player.name).join(" & ")} ·{" "}
|
{winners.map((player, index) => (
|
||||||
|
<span key={player.id}>
|
||||||
|
{index > 0 ? " & " : ""}
|
||||||
|
<Username
|
||||||
|
name={player.name}
|
||||||
|
appearance={player.appearance}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
))}{" "}
|
||||||
|
·{" "}
|
||||||
{t("{count} points", {
|
{t("{count} points", {
|
||||||
count: formatNumber(winners[0]?.total ?? 0),
|
count: formatNumber(winners[0]?.total ?? 0),
|
||||||
})}
|
})}
|
||||||
@@ -334,7 +347,10 @@ export function MultiplayerRoom({ id }: { id: string }) {
|
|||||||
>
|
>
|
||||||
<td>{player.rank}</td>
|
<td>{player.rank}</td>
|
||||||
<td>
|
<td>
|
||||||
{player.name}
|
<Username
|
||||||
|
name={player.name}
|
||||||
|
appearance={player.appearance}
|
||||||
|
/>
|
||||||
{player.left && <small> · {t("Left room")}</small>}
|
{player.left && <small> · {t("Left room")}</small>}
|
||||||
</td>
|
</td>
|
||||||
{room.rounds.map((turn) => (
|
{room.rounds.map((turn) => (
|
||||||
|
|||||||
19
components/ReportBugButton.tsx
Normal file
19
components/ReportBugButton.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"use client";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { Bug } from "lucide-react";
|
||||||
|
import { useI18n } from "./I18nProvider";
|
||||||
|
export function ReportBugButton() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const pathname = usePathname();
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
className="header-report"
|
||||||
|
href={`/support?from=${encodeURIComponent(pathname)}`}
|
||||||
|
aria-label={t("Report a bug")}
|
||||||
|
>
|
||||||
|
<Bug size={18} aria-hidden="true" />
|
||||||
|
<span>{t("Report a bug")}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { Username } from "./Username";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { MessageCircle, Send, X } from "lucide-react";
|
import { MessageCircle, Send, X } from "lucide-react";
|
||||||
@@ -197,7 +198,7 @@ export function RoomChat({
|
|||||||
className={message.playerId === playerId ? "room-chat-mine" : ""}
|
className={message.playerId === playerId ? "room-chat-mine" : ""}
|
||||||
>
|
>
|
||||||
<strong>
|
<strong>
|
||||||
{message.name}
|
<Username name={message.name} appearance={message.appearance} />
|
||||||
{message.playerId === playerId && ` ${t("(you)")}`}
|
{message.playerId === playerId && ` ${t("(you)")}`}
|
||||||
</strong>
|
</strong>
|
||||||
<span>{message.text}</span>
|
<span>{message.text}</span>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { LanguageSwitcher } from "./LanguageSwitcher";
|
|||||||
import { user } from "@/lib/supabase";
|
import { user } from "@/lib/supabase";
|
||||||
import { Brand } from "./Brand";
|
import { Brand } from "./Brand";
|
||||||
import { SiteNav } from "./SiteNav";
|
import { SiteNav } from "./SiteNav";
|
||||||
|
import { ReportBugButton } from "./ReportBugButton";
|
||||||
export async function Header() {
|
export async function Header() {
|
||||||
const { t } = await getI18n();
|
const { t } = await getI18n();
|
||||||
const account = await user();
|
const account = await user();
|
||||||
@@ -14,6 +15,8 @@ export async function Header() {
|
|||||||
<div className="header-inner">
|
<div className="header-inner">
|
||||||
<Brand />
|
<Brand />
|
||||||
<SiteNav />
|
<SiteNav />
|
||||||
|
<div className="header-actions">
|
||||||
|
<ReportBugButton />
|
||||||
<Link
|
<Link
|
||||||
href={account ? "/dashboard" : "/log-in"}
|
href={account ? "/dashboard" : "/log-in"}
|
||||||
className="header-account"
|
className="header-account"
|
||||||
@@ -21,6 +24,7 @@ export async function Header() {
|
|||||||
{account ? t("My account") : t("Log in")} <ArrowUpRight size={15} />
|
{account ? t("My account") : t("Log in")} <ArrowUpRight size={15} />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
465
components/Support.tsx
Normal file
465
components/Support.tsx
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
"use client";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Bug, ImagePlus, Send, Sparkles } from "lucide-react";
|
||||||
|
import { api } from "@/lib/client-api";
|
||||||
|
import {
|
||||||
|
bugReportSchema,
|
||||||
|
bugMessageSchema,
|
||||||
|
screenshotLimit,
|
||||||
|
type BugReportView,
|
||||||
|
} from "@/shared/bug-reports";
|
||||||
|
import { useI18n } from "./I18nProvider";
|
||||||
|
import { Username } from "./Username";
|
||||||
|
|
||||||
|
type Inbox = {
|
||||||
|
ready: boolean;
|
||||||
|
signedIn: boolean;
|
||||||
|
displayName: string | null;
|
||||||
|
nameGlow: boolean;
|
||||||
|
reports: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
state: string;
|
||||||
|
created_at: string;
|
||||||
|
linear_identifier: string | null;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
export function SupportInbox({ pagePath }: { pagePath: string }) {
|
||||||
|
const { t, formatDate } = useI18n(),
|
||||||
|
router = useRouter();
|
||||||
|
const [inbox, setInbox] = useState<Inbox | null>(null),
|
||||||
|
[error, setError] = useState(""),
|
||||||
|
[busy, setBusy] = useState(false);
|
||||||
|
const [title, setTitle] = useState(""),
|
||||||
|
[description, setDescription] = useState(""),
|
||||||
|
[name, setName] = useState(""),
|
||||||
|
[file, setFile] = useState<File | null>(null);
|
||||||
|
const nonce = useRef<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let stopped = false;
|
||||||
|
api<Inbox>("bug-reports")
|
||||||
|
.then((value) => {
|
||||||
|
if (!stopped) setInbox(value);
|
||||||
|
})
|
||||||
|
.catch((failure) => {
|
||||||
|
if (!stopped) setError(failure.message);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
async function submit() {
|
||||||
|
if (busy) return;
|
||||||
|
const clientId = (nonce.current ??= crypto.randomUUID());
|
||||||
|
const body = {
|
||||||
|
clientId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
pagePath,
|
||||||
|
displayName: inbox?.signedIn ? undefined : name,
|
||||||
|
screenshot: !!file,
|
||||||
|
};
|
||||||
|
if (!bugReportSchema.safeParse(body).success) {
|
||||||
|
setError(
|
||||||
|
"Add a short title, at least 15 characters describing the bug, and your player name.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
file &&
|
||||||
|
(!["image/png", "image/jpeg", "image/webp"].includes(file.type) ||
|
||||||
|
file.size > screenshotLimit)
|
||||||
|
) {
|
||||||
|
setError("Choose a PNG, JPG, or WebP screenshot smaller than 5 MB");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const report = await api<{
|
||||||
|
id: string;
|
||||||
|
state: string;
|
||||||
|
upload: string | null;
|
||||||
|
}>("bug-reports", body);
|
||||||
|
if (report.upload && file) {
|
||||||
|
const response = await fetch(report.upload, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": file.type },
|
||||||
|
body: file,
|
||||||
|
signal: AbortSignal.timeout(60_000),
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error("Screenshot upload failed. Please try again.");
|
||||||
|
}
|
||||||
|
await api(`bug-reports/${report.id}/submit`, {});
|
||||||
|
router.push(`/support/${report.id}`);
|
||||||
|
} catch (failure) {
|
||||||
|
setError((failure as Error).message);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="support-layout">
|
||||||
|
<section className="support-form">
|
||||||
|
<span className="eyebrow">
|
||||||
|
<Bug size={16} />
|
||||||
|
{t("PLAYER SUPPORT")}
|
||||||
|
</span>
|
||||||
|
<h1>{t("Found a bug?")}</h1>
|
||||||
|
<p className="lead">{t("Tell us what happened. We’ll reply here.")}</p>
|
||||||
|
{!inbox?.signedIn && (
|
||||||
|
<p className="muted">
|
||||||
|
{t(
|
||||||
|
"Guest reports stay in this browser. Sign in to keep your conversation in your account.",
|
||||||
|
)}{" "}
|
||||||
|
<Link href="/log-in?next=%2Fsupport">{t("Log in")}</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className="error-message" role="alert">
|
||||||
|
{t(error)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{inbox && !inbox.ready && (
|
||||||
|
<p role="status">
|
||||||
|
{t(
|
||||||
|
"Bug reporting is temporarily unavailable. Please try again later.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void submit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!inbox?.signedIn && (
|
||||||
|
<>
|
||||||
|
<label htmlFor="report-name">{t("Your player name")}</label>
|
||||||
|
<input
|
||||||
|
id="report-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => {
|
||||||
|
nonce.current = null;
|
||||||
|
setName(event.target.value);
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
minLength={2}
|
||||||
|
maxLength={32}
|
||||||
|
autoComplete="nickname"
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<label htmlFor="bug-title">{t("Bug title")}</label>
|
||||||
|
<input
|
||||||
|
id="bug-title"
|
||||||
|
value={title}
|
||||||
|
onChange={(event) => {
|
||||||
|
nonce.current = null;
|
||||||
|
setTitle(event.target.value);
|
||||||
|
}}
|
||||||
|
minLength={5}
|
||||||
|
maxLength={120}
|
||||||
|
required
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<label htmlFor="bug-description">{t("What happened?")}</label>
|
||||||
|
<textarea
|
||||||
|
id="bug-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(event) => {
|
||||||
|
nonce.current = null;
|
||||||
|
setDescription(event.target.value);
|
||||||
|
}}
|
||||||
|
minLength={15}
|
||||||
|
maxLength={6000}
|
||||||
|
required
|
||||||
|
rows={7}
|
||||||
|
placeholder={t(
|
||||||
|
"What did you do, what did you expect, and what happened instead?",
|
||||||
|
)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<label htmlFor="bug-screenshot">
|
||||||
|
<ImagePlus size={16} /> {t("Screenshot (optional)")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="bug-screenshot"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) => {
|
||||||
|
nonce.current = null;
|
||||||
|
setFile(event.target.files?.[0] ?? null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<small>
|
||||||
|
{t("PNG, JPG, or WebP, up to 5 MB. Shared privately with support.")}
|
||||||
|
</small>
|
||||||
|
<button className="button primary" disabled={busy || !inbox?.ready}>
|
||||||
|
<Send size={17} />
|
||||||
|
{busy ? t("Sending…") : t("Send bug report")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section className="support-inbox">
|
||||||
|
<h2>{t("Your bug reports")}</h2>
|
||||||
|
{!inbox ? (
|
||||||
|
<p className="muted">{t("Loading…")}</p>
|
||||||
|
) : inbox.reports.length === 0 ? (
|
||||||
|
<p className="muted">
|
||||||
|
{t("Your reports and support replies will appear here.")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul>
|
||||||
|
{inbox.reports.map((report) => (
|
||||||
|
<li key={report.id}>
|
||||||
|
<Link href={`/support/${report.id}`}>
|
||||||
|
<strong>{report.title}</strong>
|
||||||
|
<small>
|
||||||
|
{formatDate(report.created_at)} ·{" "}
|
||||||
|
{report.linear_identifier ??
|
||||||
|
t(
|
||||||
|
report.state === "draft"
|
||||||
|
? "Draft"
|
||||||
|
: "Waiting for support",
|
||||||
|
)}
|
||||||
|
</small>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SupportConversation({ id }: { id: string }) {
|
||||||
|
const { t, formatDate } = useI18n();
|
||||||
|
const [report, setReport] = useState<BugReportView | null>(null),
|
||||||
|
[error, setError] = useState(""),
|
||||||
|
[text, setText] = useState(""),
|
||||||
|
[busy, setBusy] = useState(false);
|
||||||
|
const pending = useRef<{ clientId: string; text: string } | null>(null),
|
||||||
|
version = useRef(0),
|
||||||
|
acting = useRef(false);
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (acting.current) return;
|
||||||
|
const current = ++version.current;
|
||||||
|
try {
|
||||||
|
const value = await api<BugReportView>(`bug-reports/${id}`);
|
||||||
|
if (current === version.current) {
|
||||||
|
setReport(value);
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
} catch (failure) {
|
||||||
|
if (current === version.current) setError((failure as Error).message);
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
useEffect(() => {
|
||||||
|
let stopped = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
const poll = async () => {
|
||||||
|
await load();
|
||||||
|
if (!stopped) timer = setTimeout(poll, document.hidden ? 30_000 : 10_000);
|
||||||
|
};
|
||||||
|
void poll();
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
version.current++;
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [load]);
|
||||||
|
async function action(kind: string, body: object) {
|
||||||
|
if (acting.current) return;
|
||||||
|
acting.current = true;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
const current = ++version.current;
|
||||||
|
try {
|
||||||
|
const value = await api<BugReportView>(`bug-reports/${id}/${kind}`, body);
|
||||||
|
if (current === version.current) {
|
||||||
|
setReport(value);
|
||||||
|
if (kind === "messages") {
|
||||||
|
setText("");
|
||||||
|
pending.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (failure) {
|
||||||
|
if (current === version.current) setError((failure as Error).message);
|
||||||
|
} finally {
|
||||||
|
acting.current = false;
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function send() {
|
||||||
|
const draft = text.trim();
|
||||||
|
const body =
|
||||||
|
pending.current?.text === draft
|
||||||
|
? pending.current
|
||||||
|
: { text: draft, clientId: crypto.randomUUID() };
|
||||||
|
if (!bugMessageSchema.safeParse(body).success) {
|
||||||
|
setError("Use 1–6000 characters without hidden controls");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pending.current = body;
|
||||||
|
await action("messages", body);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="support-conversation">
|
||||||
|
<Link className="text-link" href="/support">
|
||||||
|
{t("← Your bug reports")}
|
||||||
|
</Link>
|
||||||
|
{error && (
|
||||||
|
<p className="error-message" role="alert">
|
||||||
|
{t(error)}{" "}
|
||||||
|
<button className="text-link" onClick={() => void load()}>
|
||||||
|
{t("Retry")}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!report ? (
|
||||||
|
<p>{t("Loading…")}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="eyebrow">
|
||||||
|
{t("PLAYER SUPPORT")} ·{" "}
|
||||||
|
{report.linear_identifier ?? t("Waiting for support")}
|
||||||
|
</span>
|
||||||
|
<h1>{report.title}</h1>
|
||||||
|
{report.state === "pending" && (
|
||||||
|
<p role="status">
|
||||||
|
{t("Your report is saved. We’re connecting it to support.")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{report.state === "draft" && (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
"This report is not sent yet. Return to the form or finish sending it.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="button primary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void action("submit", {})}
|
||||||
|
>
|
||||||
|
{t("Finish sending report")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{report.canClaim && (
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void action("claim", {})}
|
||||||
|
>
|
||||||
|
{t("Keep this report in my account")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{report.glow_requested && (
|
||||||
|
<p className="support-reward">
|
||||||
|
<Sparkles size={18} />
|
||||||
|
{t("Support awarded you a username glow.")}{" "}
|
||||||
|
{!report.canClaim && (
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
report.isGuest
|
||||||
|
? `/log-in?next=${encodeURIComponent(`/support/${id}`)}`
|
||||||
|
: "/settings"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{report.isGuest ? t("Log in") : t("Account settings")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{report.isGuest && !report.canClaim && (
|
||||||
|
<small>
|
||||||
|
{t(
|
||||||
|
"For guest reports, sign in and link this report to receive your reward.",
|
||||||
|
)}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{report.screenshot && (
|
||||||
|
<a href={report.screenshot} target="_blank" rel="noreferrer">
|
||||||
|
<img
|
||||||
|
className="support-screenshot"
|
||||||
|
src={report.screenshot}
|
||||||
|
alt={t("Attached bug screenshot")}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="support-messages"
|
||||||
|
role="log"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-relevant="additions"
|
||||||
|
aria-label={t("Support conversation")}
|
||||||
|
>
|
||||||
|
{report.messages.map((message) => (
|
||||||
|
<article
|
||||||
|
key={message.id}
|
||||||
|
className={`support-message support-message-${message.kind}`}
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<strong>
|
||||||
|
{message.kind === "user" ? (
|
||||||
|
<Username
|
||||||
|
name={message.author_name}
|
||||||
|
appearance={report.authorAppearance}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
t("Support")
|
||||||
|
)}
|
||||||
|
</strong>
|
||||||
|
<small>{formatDate(message.created_at)}</small>
|
||||||
|
</header>
|
||||||
|
<p>
|
||||||
|
{message.kind === "system" ? t(message.body) : message.body}
|
||||||
|
</p>
|
||||||
|
{message.kind === "user" && (
|
||||||
|
<small>
|
||||||
|
{message.delivered
|
||||||
|
? t("Delivered to support")
|
||||||
|
: t("Saved · waiting to send")}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{report.state !== "draft" && (
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void send();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label htmlFor="support-message">{t("Message support")}</label>
|
||||||
|
<textarea
|
||||||
|
id="support-message"
|
||||||
|
value={text}
|
||||||
|
onChange={(event) => setText(event.target.value)}
|
||||||
|
maxLength={6000}
|
||||||
|
rows={3}
|
||||||
|
required
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="button primary"
|
||||||
|
disabled={busy || !text.trim()}
|
||||||
|
>
|
||||||
|
<Send size={16} />
|
||||||
|
{t("Send message")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
components/Username.tsx
Normal file
77
components/Username.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
import {
|
||||||
|
Bug,
|
||||||
|
ShieldCheck,
|
||||||
|
Star,
|
||||||
|
Crown,
|
||||||
|
Heart,
|
||||||
|
Flame,
|
||||||
|
Gem,
|
||||||
|
Trophy,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useI18n } from "./I18nProvider";
|
||||||
|
import { glowColor, type ProfileAppearance } from "@/shared/profile-appearance";
|
||||||
|
export const badgeIcons = {
|
||||||
|
star: Star,
|
||||||
|
crown: Crown,
|
||||||
|
heart: Heart,
|
||||||
|
flame: Flame,
|
||||||
|
gem: Gem,
|
||||||
|
trophy: Trophy,
|
||||||
|
sparkles: Sparkles,
|
||||||
|
};
|
||||||
|
export function Username({
|
||||||
|
name,
|
||||||
|
glow,
|
||||||
|
appearance = {},
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
glow?: boolean;
|
||||||
|
appearance?: ProfileAppearance;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const color = glowColor(appearance.glow_color),
|
||||||
|
enabled = glow ?? appearance.name_glow;
|
||||||
|
const Icon =
|
||||||
|
badgeIcons[appearance.custom_prefix_icon as keyof typeof badgeIcons] ??
|
||||||
|
Star;
|
||||||
|
return (
|
||||||
|
<span className="username">
|
||||||
|
{appearance.role === "moderator" && (
|
||||||
|
<span
|
||||||
|
className="username-prefix username-moderator"
|
||||||
|
title={t("Moderator")}
|
||||||
|
>
|
||||||
|
<ShieldCheck size={13} aria-hidden="true" />
|
||||||
|
{t("Moderator")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{appearance.bug_finder && (
|
||||||
|
<span
|
||||||
|
className="username-prefix username-bug-finder"
|
||||||
|
title={t("Bug finder")}
|
||||||
|
>
|
||||||
|
<Bug size={13} aria-hidden="true" />
|
||||||
|
{t("Bug finder")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{appearance.custom_prefix && (
|
||||||
|
<span className="username-prefix username-custom-prefix">
|
||||||
|
<Icon size={13} aria-hidden="true" />
|
||||||
|
{appearance.custom_prefix}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={enabled ? "username-glow" : undefined}
|
||||||
|
style={
|
||||||
|
enabled
|
||||||
|
? { color, textShadow: `0 0 8px ${color}90, 0 0 18px ${color}60` }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
docs/bug-reports.md
Normal file
74
docs/bug-reports.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# Player bug reports
|
||||||
|
|
||||||
|
The header's **Send bug report** button opens `/support`. Players can report a
|
||||||
|
bug with an optional PNG, JPEG, or WebP screenshot up to 5 MB. Each submitted
|
||||||
|
report creates one Medium-priority issue in Linear. Retries reuse server-generated
|
||||||
|
issue and comment IDs. Screenshots are normalized, stripped of metadata, and
|
||||||
|
uploaded to private Supabase Storage and Linear assets.
|
||||||
|
|
||||||
|
Signed-in players keep reports in their account. Guests keep access through a
|
||||||
|
180-day HttpOnly browser cookie. A guest can sign in in the same browser and
|
||||||
|
choose **Keep this report in my account**. Clearing the cookie before linking
|
||||||
|
loses guest access. Conversations and screenshots are private.
|
||||||
|
|
||||||
|
## Linear commands
|
||||||
|
|
||||||
|
Add a new comment on the report's issue beginning with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
#player-report-send Dear user, we found the bug. Thank you for reporting it!
|
||||||
|
```
|
||||||
|
|
||||||
|
The text after the prefix appears in the player's conversation. Other staff
|
||||||
|
comments remain internal. Player follow-ups appear in Linear with
|
||||||
|
`#user-report-message`; the initial report uses the same prefix in its description.
|
||||||
|
Editing an existing comment does not send another message.
|
||||||
|
|
||||||
|
To grant or remove the actual account reward, add a separate new comment:
|
||||||
|
|
||||||
|
```text
|
||||||
|
#player-report-glow grant
|
||||||
|
#player-report-glow revoke
|
||||||
|
```
|
||||||
|
|
||||||
|
Use one command per comment. A guest reward is held until the report is linked
|
||||||
|
to an account. The glow appears beside account names on the dashboard, settings,
|
||||||
|
leaderboard, and public map credits. Only verified Linear webhook commands can
|
||||||
|
change rewards; browser writes cannot grant them.
|
||||||
|
|
||||||
|
The follow-up migration `20261008075322_profile_identity.sql` adds a permanent
|
||||||
|
**Bug finder** badge when a glow is granted. Revoking the glow preserves the badge.
|
||||||
|
Guest reports retain confirmed-bug recognition when linked after a glow revoke.
|
||||||
|
Players with an active glow can choose any hex color in **Settings → Username glow**.
|
||||||
|
Moderators display a shield badge from their account role and can find an account
|
||||||
|
in **Moderation → Player prefixes** to assign, change, or remove a custom text/icon
|
||||||
|
prefix. Account IDs disambiguate players with identical names. Prefixes do not
|
||||||
|
change permissions. These decorations appear in account views, leaderboards,
|
||||||
|
map credits, support conversations, and authenticated multiplayer rosters and chat. Guests cannot gain
|
||||||
|
badges by copying a registered player's display name.
|
||||||
|
|
||||||
|
## Deployment setup
|
||||||
|
|
||||||
|
Apply `20261008071054_bug_reports.sql` and `20261008075322_profile_identity.sql`
|
||||||
|
before deploying. Production environment
|
||||||
|
variables in Vercel:
|
||||||
|
|
||||||
|
- `LINEAR_API_KEY`: sensitive personal API key with Read, Create issues, and
|
||||||
|
Create comments access to the support team.
|
||||||
|
- `LINEAR_WEBHOOK_SECRET`: sensitive signing secret of the team's Linear webhook.
|
||||||
|
- `LINEAR_TEAM_ID`: support team UUID.
|
||||||
|
- `LINEAR_PROJECT_ID`: optional project UUID or Linear project identifier.
|
||||||
|
|
||||||
|
Enable **Comment** events on the team webhook and set its URL to
|
||||||
|
`https://minegeo.vercel.app/api/support/linear`. Redeploy after saving environment
|
||||||
|
variables. Keys stay server-side and must never be pasted into issue descriptions,
|
||||||
|
client configuration, or source uploads.
|
||||||
|
|
||||||
|
Webhook requests require a matching raw-body HMAC signature and a timestamp
|
||||||
|
within 60 seconds. Duplicate events have no effect. Delivery to Linear happens
|
||||||
|
after the HTTP response; opening the conversation retries interrupted or failed
|
||||||
|
delivery. The page shows saved/pending and delivered states.
|
||||||
|
|
||||||
|
Validation: `npm test`, `npm run typecheck`, `npm run build`, and
|
||||||
|
`node scripts/test-bug-reports-sql.mjs` (uses the isolated PGlite installation under
|
||||||
|
`.data/sql-verification`, as the other SQL verification scripts do).
|
||||||
@@ -49,3 +49,9 @@ private and can be played without moderation; publication is blocked. See
|
|||||||
|
|
||||||
The script targets the existing MineGeo project. Set `VERCEL_PROJECT_ID` and
|
The script targets the existing MineGeo project. Set `VERCEL_PROJECT_ID` and
|
||||||
`VERCEL_ORG_ID` to deploy a copy to another Vercel project.
|
`VERCEL_ORG_ID` to deploy a copy to another Vercel project.
|
||||||
|
|
||||||
|
Player bug reporting requires `20261008071054_bug_reports.sql` and the Linear
|
||||||
|
server credentials described in [bug-reports.md](bug-reports.md). Configure the
|
||||||
|
Comment webhook before deploying so staff replies and glow commands reach players.
|
||||||
|
Glow colors and user prefixes also require `20261008075322_profile_identity.sql`
|
||||||
|
before deploying the updated account, catalog, and multiplayer pages.
|
||||||
|
|||||||
@@ -70,13 +70,27 @@ export async function accountApi(
|
|||||||
});
|
});
|
||||||
if (request.method === "PATCH") {
|
if (request.method === "PATCH") {
|
||||||
const body = profileUpdateSchema.parse(await context.jsonBody(request));
|
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(
|
const profile = required(
|
||||||
await admin()
|
await admin()
|
||||||
.from("profiles")
|
.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)
|
.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(),
|
.single(),
|
||||||
);
|
);
|
||||||
return json({ profile });
|
return json({ profile });
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { ServiceError, checkServiceError } from "./service-error";
|
|||||||
import { requestOrigin, verifyOrigin } from "./request-origin";
|
import { requestOrigin, verifyOrigin } from "./request-origin";
|
||||||
import { accountApi } from "./account-api";
|
import { accountApi } from "./account-api";
|
||||||
import { multiplayerApi } from "./multiplayer-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 { contributionCutoff, reportSchema } from "../shared/moderation";
|
||||||
import { siteSettingsSchema } from "../shared/site-settings";
|
import { siteSettingsSchema } from "../shared/site-settings";
|
||||||
import { getSiteSettings } from "./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 });
|
return await accountApi(request, path, { jsonBody, limit });
|
||||||
if (path[0] === "rooms")
|
if (path[0] === "rooms")
|
||||||
return await multiplayerApi(request, path, { jsonBody, limit });
|
return await multiplayerApi(request, path, { jsonBody, limit });
|
||||||
|
if (path[0] === "bug-reports")
|
||||||
|
return await bugReportApi(request, path, { jsonBody, limit });
|
||||||
const client = admin(),
|
const client = admin(),
|
||||||
method = request.method;
|
method = request.method;
|
||||||
if (path[0] === "site-settings" && path.length === 1 && method === "GET") {
|
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") {
|
if (path[0] === "moderation") {
|
||||||
const reviewer = await moderator(request);
|
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") {
|
if (path[1] === "settings" && path.length === 2 && method === "PATCH") {
|
||||||
const body = siteSettingsSchema.parse(await jsonBody(request));
|
const body = siteSettingsSchema.parse(await jsonBody(request));
|
||||||
const enabled = required(
|
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;
|
image?: string;
|
||||||
mapImage?: string;
|
mapImage?: string;
|
||||||
credit?: string;
|
credit?: string;
|
||||||
|
creditGlow?: boolean;
|
||||||
|
creditAppearance?: import("../shared/profile-appearance").ProfileAppearance;
|
||||||
};
|
};
|
||||||
export async function mediaUrl(path: string | null, expires = 3600) {
|
export async function mediaUrl(path: string | null, expires = 3600) {
|
||||||
return (await mediaUrls([path], expires))[0];
|
return (await mediaUrls([path], expires))[0];
|
||||||
@@ -61,7 +63,9 @@ export async function catalog() {
|
|||||||
? required(
|
? required(
|
||||||
await client
|
await client
|
||||||
.from("profiles")
|
.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))]),
|
.in("id", [...new Set(maps.map((m) => m.owner_id))]),
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
@@ -71,6 +75,8 @@ export async function catalog() {
|
|||||||
return maps.map((m, index) => ({
|
return maps.map((m, index) => ({
|
||||||
...m,
|
...m,
|
||||||
credit: owners.find((p) => p.id === m.owner_id)?.display_name ?? "Explorer",
|
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,
|
image: signed[index * 2] ?? undefined,
|
||||||
mapImage: signed[index * 2 + 1] ?? undefined,
|
mapImage: signed[index * 2 + 1] ?? undefined,
|
||||||
server: servers.find((s) => s.id === m.server_id),
|
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([
|
const [owner, signed] = await Promise.all([
|
||||||
admin()
|
admin()
|
||||||
.from("profiles")
|
.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)
|
.eq("id", map.owner_id)
|
||||||
.maybeSingle(),
|
.maybeSingle(),
|
||||||
mediaUrls([map.cover_path, map.map_path]),
|
mediaUrls([map.cover_path, map.map_path]),
|
||||||
@@ -98,6 +106,8 @@ export async function getMap(id: string) {
|
|||||||
return {
|
return {
|
||||||
...map,
|
...map,
|
||||||
credit: owner.data?.display_name ?? "Explorer",
|
credit: owner.data?.display_name ?? "Explorer",
|
||||||
|
creditGlow: owner.data?.name_glow ?? false,
|
||||||
|
creditAppearance: owner.data ?? undefined,
|
||||||
image: signed[0],
|
image: signed[0],
|
||||||
mapImage: signed[1],
|
mapImage: signed[1],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export async function leaderboard({
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
} = {}): Promise<LeaderboardData | null> {
|
} = {}): Promise<LeaderboardData | null> {
|
||||||
try {
|
try {
|
||||||
return required(
|
const data = required(
|
||||||
await admin().rpc("player_leaderboard", {
|
await admin().rpc("player_leaderboard", {
|
||||||
p_difficulty: difficulty,
|
p_difficulty: difficulty,
|
||||||
p_search: search,
|
p_search: search,
|
||||||
@@ -25,6 +25,30 @@ export async function leaderboard({
|
|||||||
p_limit: limit,
|
p_limit: limit,
|
||||||
}),
|
}),
|
||||||
) as unknown as LeaderboardData;
|
) 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) {
|
} catch (error) {
|
||||||
// A leaderboard outage must not take the map catalog or homepage offline.
|
// A leaderboard outage must not take the map catalog or homepage offline.
|
||||||
console.error(
|
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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { admin, required, user } from "./supabase";
|
import { admin, required, user } from "./supabase";
|
||||||
|
import { accountProfile } from "./profile";
|
||||||
|
import { multiplayerAppearance } from "./multiplayer-appearance";
|
||||||
import { mediaUrls } from "./catalog";
|
import { mediaUrls } from "./catalog";
|
||||||
import { requestOrigin } from "./request-origin";
|
import { requestOrigin } from "./request-origin";
|
||||||
import { ServiceError } from "./service-error";
|
import { ServiceError } from "./service-error";
|
||||||
@@ -13,6 +15,7 @@ import {
|
|||||||
roomGuessSchema,
|
roomGuessSchema,
|
||||||
chatMessageSchema,
|
chatMessageSchema,
|
||||||
type RoomView,
|
type RoomView,
|
||||||
|
type RoomChat,
|
||||||
} from "../shared/multiplayer";
|
} from "../shared/multiplayer";
|
||||||
import {
|
import {
|
||||||
datasetSchema,
|
datasetSchema,
|
||||||
@@ -55,6 +58,7 @@ export async function multiplayerApi(
|
|||||||
const body = createRoomSchema.parse(await jsonBody(request));
|
const body = createRoomSchema.parse(await jsonBody(request));
|
||||||
await limit("multiplayer:create:" + accessHash, 20, 3600);
|
await limit("multiplayer:create:" + accessHash, 20, 3600);
|
||||||
const account = await user();
|
const account = await user();
|
||||||
|
if (account) await accountProfile(account);
|
||||||
const id = required(
|
const id = required(
|
||||||
await client.rpc("create_map_room", {
|
await client.rpc("create_map_room", {
|
||||||
p_dataset: body.datasetId,
|
p_dataset: body.datasetId,
|
||||||
@@ -65,6 +69,16 @@ export async function multiplayerApi(
|
|||||||
p_owner: account?.id ?? null,
|
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 });
|
return respond({ id });
|
||||||
}
|
}
|
||||||
const id = z.string().uuid().parse(path[1]);
|
const id = z.string().uuid().parse(path[1]);
|
||||||
@@ -81,16 +95,25 @@ export async function multiplayerApi(
|
|||||||
? chatMessageSchema.parse(await jsonBody(request))
|
? chatMessageSchema.parse(await jsonBody(request))
|
||||||
: null;
|
: null;
|
||||||
if (body) await limit("multiplayer:chat:" + accessHash, 60, 60);
|
if (body) await limit("multiplayer:chat:" + accessHash, 60, 60);
|
||||||
return respond(
|
const chat = required(
|
||||||
required(
|
|
||||||
await client.rpc("multiplayer_chat", {
|
await client.rpc("multiplayer_chat", {
|
||||||
p_room: id,
|
p_room: id,
|
||||||
p_access_hash: accessHash,
|
p_access_hash: accessHash,
|
||||||
p_text: body?.text ?? null,
|
p_text: body?.text ?? null,
|
||||||
p_client_id: body?.clientId ?? 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> = {};
|
let payload: Record<string, string | number> = {};
|
||||||
if (request.method === "POST" && path.length === 3) {
|
if (request.method === "POST" && path.length === 3) {
|
||||||
@@ -121,6 +144,32 @@ export async function multiplayerApi(
|
|||||||
p_payload: payload,
|
p_payload: payload,
|
||||||
}),
|
}),
|
||||||
) as unknown as RoomView;
|
) 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 (action !== "media") return respond(room);
|
||||||
if (!room.you) throw new ServiceError("Join this room to play", 403);
|
if (!room.you) throw new ServiceError("Join this room to play", 403);
|
||||||
if (["lobby", "closed"].includes(room.status))
|
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(
|
return required(
|
||||||
await admin()
|
await admin()
|
||||||
.from("profiles")
|
.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)
|
.eq("id", account.id)
|
||||||
.single(),
|
.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));
|
||||||
|
}
|
||||||
286
scripts/test-bug-reports-browser.mjs
Normal file
286
scripts/test-bug-reports-browser.mjs
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { randomUUID, randomBytes } from "node:crypto";
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import { chromium } from "@playwright/test";
|
||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
process.loadEnvFile(".env.local");
|
||||||
|
const origin = process.argv[2] || "https://minegeo.vercel.app";
|
||||||
|
const db = createClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||||
|
process.env.SUPABASE_SECRET_KEY,
|
||||||
|
{ auth: { persistSession: false, autoRefreshToken: false } },
|
||||||
|
);
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({ locale: "en-US" });
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.setDefaultTimeout(30000);
|
||||||
|
let reportId, accountId;
|
||||||
|
const checked = (result) => {
|
||||||
|
if (result.error) throw new Error(result.error.message);
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
const statePath = ".data/support-deploy/fixture.json";
|
||||||
|
async function phase(value) {
|
||||||
|
await writeFile(statePath, JSON.stringify({ reportId, phase: value }));
|
||||||
|
console.log("Support verification: " + value);
|
||||||
|
}
|
||||||
|
async function until(check, label, timeout = 120000) {
|
||||||
|
const end = Date.now() + timeout;
|
||||||
|
while (Date.now() < end) {
|
||||||
|
if (await check()) return;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
|
throw new Error("Timed out: " + label);
|
||||||
|
}
|
||||||
|
async function json(path, body) {
|
||||||
|
const response =
|
||||||
|
body === undefined
|
||||||
|
? await context.request.get(origin + "/api/v1/" + path)
|
||||||
|
: await context.request.post(origin + "/api/v1/" + path, {
|
||||||
|
headers: { origin },
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
assert.equal(response.status(), 200, await response.text());
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await mkdir(".data/support-deploy", { recursive: true });
|
||||||
|
await page.goto(origin, { waitUntil: "networkidle" });
|
||||||
|
assert.equal(await page.locator(".header-actions > a").count(), 2);
|
||||||
|
await page.getByRole("link", { name: "Report a bug", exact: true }).click();
|
||||||
|
await page.getByLabel("Your player name").fill("Support verification");
|
||||||
|
await page
|
||||||
|
.getByLabel("Bug title", { exact: true })
|
||||||
|
.fill("[Verification] Player support " + randomUUID().slice(0, 8));
|
||||||
|
await page
|
||||||
|
.getByLabel("What happened?", { exact: true })
|
||||||
|
.fill(
|
||||||
|
"Temporary automated verification of the private support conversation and screenshot.",
|
||||||
|
);
|
||||||
|
const image = await sharp({
|
||||||
|
create: { width: 180, height: 100, channels: 3, background: "#8dce66" },
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
await page
|
||||||
|
.getByLabel("Screenshot (optional)")
|
||||||
|
.setInputFiles({
|
||||||
|
name: "support-test.png",
|
||||||
|
mimeType: "image/png",
|
||||||
|
buffer: image,
|
||||||
|
});
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Send bug report", exact: true })
|
||||||
|
.click();
|
||||||
|
await page.waitForURL(/\/support\/[0-9a-f-]{36}$/, { timeout: 60000 });
|
||||||
|
reportId = page.url().split("/").pop();
|
||||||
|
await phase("submitted");
|
||||||
|
await until(async () => {
|
||||||
|
const r = await json("bug-reports/" + reportId);
|
||||||
|
return r.state === "open" && r.messages[0]?.delivered;
|
||||||
|
}, "Linear issue creation");
|
||||||
|
const report = checked(
|
||||||
|
await db.from("bug_reports").select("*").eq("id", reportId).single(),
|
||||||
|
);
|
||||||
|
assert.ok(report.linear_identifier);
|
||||||
|
assert.match(report.linear_asset_url, /^https:/);
|
||||||
|
assert.ok(report.screenshot_path.endsWith("/screenshot.webp"));
|
||||||
|
assert.notEqual(report.client_id, report.id);
|
||||||
|
const retry = await json("bug-reports", {
|
||||||
|
clientId: report.client_id,
|
||||||
|
title: report.title,
|
||||||
|
description: report.description,
|
||||||
|
displayName: report.display_name,
|
||||||
|
pagePath: report.page_path,
|
||||||
|
screenshot: true,
|
||||||
|
});
|
||||||
|
assert.equal(retry.id, reportId);
|
||||||
|
assert.equal(retry.upload, null);
|
||||||
|
await Promise.all([
|
||||||
|
json("bug-reports/" + reportId + "/submit", {}),
|
||||||
|
json("bug-reports/" + reportId + "/submit", {}),
|
||||||
|
]);
|
||||||
|
const stranger = await browser.newContext();
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await stranger.request.get(origin + "/api/v1/bug-reports/" + reportId)
|
||||||
|
).status(),
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await stranger.request.post(origin + "/api/v1/bug-reports", {
|
||||||
|
headers: { origin: "https://foreign.example" },
|
||||||
|
data: {},
|
||||||
|
})
|
||||||
|
).status(),
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
await stranger.close();
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await context.request.post(origin + "/api/support/linear", {
|
||||||
|
data: { type: "Comment" },
|
||||||
|
headers: { "linear-signature": "0".repeat(64) },
|
||||||
|
})
|
||||||
|
).status(),
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
const anonymous = createClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
|
||||||
|
{ auth: { persistSession: false } },
|
||||||
|
);
|
||||||
|
assert.ok((await anonymous.from("bug_reports").select("id")).error);
|
||||||
|
const text = "Browser reply verification.\n#player-report-glow grant";
|
||||||
|
await page.getByLabel("Message support", { exact: true }).fill(text);
|
||||||
|
await page.getByRole("button", { name: "Send message", exact: true }).click();
|
||||||
|
await until(async () => {
|
||||||
|
const r = await json("bug-reports/" + reportId);
|
||||||
|
return r.messages.some((m) => m.body === text && m.delivered);
|
||||||
|
}, "player comment delivery");
|
||||||
|
const sent = checked(
|
||||||
|
await db
|
||||||
|
.from("bug_report_messages")
|
||||||
|
.select("client_id")
|
||||||
|
.eq("report_id", reportId)
|
||||||
|
.eq("body", text)
|
||||||
|
.single(),
|
||||||
|
);
|
||||||
|
await json("bug-reports/" + reportId + "/messages", {
|
||||||
|
clientId: sent.client_id,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
checked(
|
||||||
|
await db
|
||||||
|
.from("bug_report_messages")
|
||||||
|
.select("id")
|
||||||
|
.eq("report_id", reportId)
|
||||||
|
.eq("body", text),
|
||||||
|
).length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const password = randomBytes(24).toString("base64url"),
|
||||||
|
email = "support-verify-" + randomUUID() + "@example.com";
|
||||||
|
const account = checked(
|
||||||
|
await db.auth.admin.createUser({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
email_confirm: true,
|
||||||
|
user_metadata: { display_name: "Support verification" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
accountId = account.user.id;
|
||||||
|
await json("auth", {
|
||||||
|
action: "login",
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
next: "/support/" + reportId,
|
||||||
|
});
|
||||||
|
await page.reload({ waitUntil: "networkidle" });
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Keep this report in my account" })
|
||||||
|
.click();
|
||||||
|
await until(
|
||||||
|
async () =>
|
||||||
|
checked(
|
||||||
|
await db
|
||||||
|
.from("bug_reports")
|
||||||
|
.select("owner_id")
|
||||||
|
.eq("id", reportId)
|
||||||
|
.single(),
|
||||||
|
).owner_id === accountId,
|
||||||
|
"guest report claim",
|
||||||
|
);
|
||||||
|
await phase("ready-for-staff");
|
||||||
|
await until(
|
||||||
|
async () => {
|
||||||
|
const r = await json("bug-reports/" + reportId);
|
||||||
|
return (
|
||||||
|
r.glow_requested &&
|
||||||
|
r.messages.some(
|
||||||
|
(m) => m.kind === "staff" && m.body === "Verified support reply.",
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
"staff reply and glow",
|
||||||
|
240000,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
checked(
|
||||||
|
await db
|
||||||
|
.from("profiles")
|
||||||
|
.select("name_glow")
|
||||||
|
.eq("id", accountId)
|
||||||
|
.single(),
|
||||||
|
).name_glow,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await page.goto(origin + "/settings", { waitUntil: "networkidle" });
|
||||||
|
await page
|
||||||
|
.locator(".username-glow")
|
||||||
|
.filter({ hasText: "Support verification" })
|
||||||
|
.waitFor();
|
||||||
|
await page.goto(origin + "/support/" + reportId, {
|
||||||
|
waitUntil: "networkidle",
|
||||||
|
});
|
||||||
|
await page.getByText("Verified support reply.", { exact: true }).waitFor();
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
assert.ok(
|
||||||
|
await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth <= innerWidth + 1,
|
||||||
|
),
|
||||||
|
"mobile overflow",
|
||||||
|
);
|
||||||
|
await page.screenshot({
|
||||||
|
path: ".data/support-deploy/conversation-mobile.png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
const view = await json("bug-reports/" + reportId);
|
||||||
|
assert.equal(
|
||||||
|
view.messages.filter((m) => m.kind === "staff").length,
|
||||||
|
1,
|
||||||
|
"internal comments stay internal",
|
||||||
|
);
|
||||||
|
await phase("ready-for-revoke");
|
||||||
|
await until(
|
||||||
|
async () =>
|
||||||
|
!checked(
|
||||||
|
await db
|
||||||
|
.from("profiles")
|
||||||
|
.select("name_glow")
|
||||||
|
.eq("id", accountId)
|
||||||
|
.single(),
|
||||||
|
).name_glow,
|
||||||
|
"reward revoke",
|
||||||
|
240000,
|
||||||
|
);
|
||||||
|
await page.goto(origin + "/settings", { waitUntil: "networkidle" });
|
||||||
|
assert.equal(await page.locator(".username-glow").count(), 0);
|
||||||
|
await phase("passed");
|
||||||
|
console.log(
|
||||||
|
"Production support passed: screenshot, one issue, retries, private access, player reply, account linking, staff reply, real glow grant/revoke, mobile UI.",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
await phase("failed");
|
||||||
|
} finally {
|
||||||
|
// Only records created by this verification are removed.
|
||||||
|
if (reportId) {
|
||||||
|
checked(
|
||||||
|
await db.storage
|
||||||
|
.from("minegeo-support")
|
||||||
|
.remove([reportId + "/input", reportId + "/screenshot.webp"]),
|
||||||
|
);
|
||||||
|
checked(await db.from("bug_reports").delete().eq("id", reportId));
|
||||||
|
}
|
||||||
|
if (accountId) {
|
||||||
|
checked(await db.from("profiles").delete().eq("id", accountId));
|
||||||
|
checked(await db.auth.admin.deleteUser(accountId));
|
||||||
|
}
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
259
scripts/test-bug-reports-sql.mjs
Normal file
259
scripts/test-bug-reports-sql.mjs
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { readFile, readdir } from "node:fs/promises";
|
||||||
|
import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js";
|
||||||
|
const db = new PGlite();
|
||||||
|
try {
|
||||||
|
await db.exec(`create role anon;create role authenticated;create role service_role;
|
||||||
|
create schema auth;create table auth.users(id uuid primary key);
|
||||||
|
create function auth.uid() returns uuid language sql as $$select null::uuid$$;
|
||||||
|
create schema storage;create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint);`);
|
||||||
|
for (const file of (await readdir("supabase/migrations"))
|
||||||
|
.filter((file) => file.endsWith(".sql"))
|
||||||
|
.sort())
|
||||||
|
await db.exec(await readFile(`supabase/migrations/${file}`, "utf8"));
|
||||||
|
const account = randomUUID();
|
||||||
|
await db.query("insert into auth.users values($1)", [account]);
|
||||||
|
await db.query(
|
||||||
|
"insert into public.profiles(id,display_name) values($1,'Support fixture')",
|
||||||
|
[account],
|
||||||
|
);
|
||||||
|
const hash = "a".repeat(64),
|
||||||
|
nonce = randomUUID();
|
||||||
|
const report = (
|
||||||
|
await db.query(
|
||||||
|
"insert into public.bug_reports(client_id,access_hash,display_name,title,description,state) values($1,$2,'Guest','Test issue','A valid test bug description.','pending') returning id",
|
||||||
|
[nonce, hash],
|
||||||
|
)
|
||||||
|
).rows[0].id;
|
||||||
|
assert.notEqual(
|
||||||
|
report,
|
||||||
|
nonce,
|
||||||
|
"Linear issue IDs are server generated, not chosen by the player",
|
||||||
|
);
|
||||||
|
const rpc = async (sql, params) => (await db.query(sql, params)).rows[0];
|
||||||
|
assert.equal(
|
||||||
|
(await rpc("select public.claim_bug_sync($1,'worker-a') as ok", [report]))
|
||||||
|
.ok,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await rpc("select public.claim_bug_sync($1,'worker-b') as ok", [report]))
|
||||||
|
.ok,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
await db.query(
|
||||||
|
"update public.bug_reports set lease_until=now()-interval '1 second' where id=$1",
|
||||||
|
[report],
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await rpc("select public.claim_bug_sync($1,'worker-b') as ok", [report]))
|
||||||
|
.ok,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const command = randomUUID();
|
||||||
|
await db.query("select public.receive_bug_reply($1,$2,'Staff','',true)", [
|
||||||
|
report,
|
||||||
|
command,
|
||||||
|
]);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await db.query(
|
||||||
|
"select glow_requested from public.bug_reports where id=$1",
|
||||||
|
[report],
|
||||||
|
)
|
||||||
|
).rows[0].glow_requested,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
db.query("select public.claim_bug_report($1,$2,$3)", [
|
||||||
|
report,
|
||||||
|
"b".repeat(64),
|
||||||
|
account,
|
||||||
|
]),
|
||||||
|
/Report not found/,
|
||||||
|
);
|
||||||
|
await db.query("select public.claim_bug_report($1,$2,$3)", [
|
||||||
|
report,
|
||||||
|
hash,
|
||||||
|
account,
|
||||||
|
]);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await db.query("select name_glow from public.profiles where id=$1", [
|
||||||
|
account,
|
||||||
|
])
|
||||||
|
).rows[0].name_glow,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await db.query("select public.receive_bug_reply($1,$2,'Staff','',false)", [
|
||||||
|
report,
|
||||||
|
randomUUID(),
|
||||||
|
]);
|
||||||
|
await db.query("select public.receive_bug_reply($1,$2,'Staff','',true)", [
|
||||||
|
report,
|
||||||
|
command,
|
||||||
|
]);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await db.query("select name_glow from public.profiles where id=$1", [
|
||||||
|
account,
|
||||||
|
])
|
||||||
|
).rows[0].name_glow,
|
||||||
|
false,
|
||||||
|
"old duplicate grant cannot override a revoke",
|
||||||
|
);
|
||||||
|
await db.query(
|
||||||
|
"select public.receive_bug_reply($1,$2,'Staff','We found the bug.',null)",
|
||||||
|
[report, randomUUID()],
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await db.query(
|
||||||
|
"select count(*)::integer as n from public.bug_report_messages where report_id=$1",
|
||||||
|
[report],
|
||||||
|
)
|
||||||
|
).rows[0].n,
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
for (const role of ["anon", "authenticated"]) {
|
||||||
|
await db.exec(`set role ${role}`);
|
||||||
|
for (const table of ["bug_reports", "bug_report_messages"])
|
||||||
|
await assert.rejects(
|
||||||
|
db.query(`select * from public.${table}`),
|
||||||
|
/permission denied/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
db.query("select public.receive_bug_reply($1,$2,'Spoof','Fake',true)", [
|
||||||
|
report,
|
||||||
|
randomUUID(),
|
||||||
|
]),
|
||||||
|
/permission denied/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
db.query("select public.claim_bug_report($1,$2,$3)", [
|
||||||
|
report,
|
||||||
|
hash,
|
||||||
|
account,
|
||||||
|
]),
|
||||||
|
/permission denied/,
|
||||||
|
);
|
||||||
|
await db.exec("reset role");
|
||||||
|
}
|
||||||
|
await db.exec(
|
||||||
|
"create role reward_probe bypassrls;grant update on public.profiles to reward_probe;grant select on public.profiles to reward_probe;set role reward_probe;",
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
db.query("update public.profiles set name_glow=true where id=$1", [
|
||||||
|
account,
|
||||||
|
]),
|
||||||
|
/Reward permission/,
|
||||||
|
);
|
||||||
|
await db.exec("reset role");
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await db.query("select bug_finder from public.profiles where id=$1", [
|
||||||
|
account,
|
||||||
|
])
|
||||||
|
).rows[0].bug_finder,
|
||||||
|
true,
|
||||||
|
"revoking glow keeps the confirmed bug-finder badge",
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
db.query("update public.profiles set glow_color='#123456' where id=$1", [
|
||||||
|
account,
|
||||||
|
]),
|
||||||
|
/Earn a username glow/,
|
||||||
|
);
|
||||||
|
const mod = randomUUID();
|
||||||
|
await db.query("insert into auth.users values($1)", [mod]);
|
||||||
|
await db.query(
|
||||||
|
"insert into public.profiles(id,role) values($1,'moderator')",
|
||||||
|
[mod],
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
db.query("select public.set_user_prefix($1,$2,'Builder','gem')", [
|
||||||
|
account,
|
||||||
|
mod,
|
||||||
|
]),
|
||||||
|
/Moderator permission/,
|
||||||
|
);
|
||||||
|
await db.query("select public.set_user_prefix($1,$2,'Builder','gem')", [
|
||||||
|
mod,
|
||||||
|
account,
|
||||||
|
]);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await db.query("select custom_prefix from public.profiles where id=$1", [
|
||||||
|
account,
|
||||||
|
])
|
||||||
|
).rows[0].custom_prefix,
|
||||||
|
"Builder",
|
||||||
|
);
|
||||||
|
await db.query("select public.set_user_prefix($1,$2,null,'star')", [
|
||||||
|
mod,
|
||||||
|
account,
|
||||||
|
]);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await db.query("select custom_prefix from public.profiles where id=$1", [
|
||||||
|
account,
|
||||||
|
])
|
||||||
|
).rows[0].custom_prefix,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
await db.exec("set role reward_probe");
|
||||||
|
for (const column of [
|
||||||
|
"role='moderator'",
|
||||||
|
"bug_finder=false",
|
||||||
|
"custom_prefix='Spoof'",
|
||||||
|
"custom_prefix_icon='crown'",
|
||||||
|
])
|
||||||
|
await assert.rejects(
|
||||||
|
db.query(`update public.profiles set ${column} where id=$1`, [account]),
|
||||||
|
/Moderator permission/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
db.query("select public.set_user_prefix($1,$2,'Spoof','star')", [
|
||||||
|
mod,
|
||||||
|
account,
|
||||||
|
]),
|
||||||
|
/permission denied/,
|
||||||
|
);
|
||||||
|
await db.exec("reset role");
|
||||||
|
const guest = (
|
||||||
|
await db.query(
|
||||||
|
"insert into public.bug_reports(client_id,access_hash,display_name,title,description,state) values($1,$2,'Guest','Guest verification','A guest verification description.','pending') returning id",
|
||||||
|
[randomUUID(), hash],
|
||||||
|
)
|
||||||
|
).rows[0].id;
|
||||||
|
await db.query("select public.receive_bug_reply($1,$2,'Staff','',true)", [
|
||||||
|
guest,
|
||||||
|
randomUUID(),
|
||||||
|
]);
|
||||||
|
await db.query("select public.receive_bug_reply($1,$2,'Staff','',false)", [
|
||||||
|
guest,
|
||||||
|
randomUUID(),
|
||||||
|
]);
|
||||||
|
await db.query("select public.claim_bug_report($1,$2,$3)", [
|
||||||
|
guest,
|
||||||
|
hash,
|
||||||
|
mod,
|
||||||
|
]);
|
||||||
|
const claimed = (
|
||||||
|
await db.query(
|
||||||
|
"select bug_finder,name_glow from public.profiles where id=$1",
|
||||||
|
[mod],
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
assert.equal(claimed.bug_finder, true);
|
||||||
|
assert.equal(claimed.name_glow, false);
|
||||||
|
console.log(
|
||||||
|
"Bug report SQL passed: private tables, lease retries, guest claims, staff reply deduplication, grant/revoke rewards and privilege isolation.",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
382
scripts/test-profile-appearance-browser.mjs
Normal file
382
scripts/test-profile-appearance-browser.mjs
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { randomUUID, randomBytes } from "node:crypto";
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import { chromium } from "@playwright/test";
|
||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
process.loadEnvFile(".env.local");
|
||||||
|
const origin = process.argv[2] || "https://minegeo.vercel.app",
|
||||||
|
reportId = process.argv[3];
|
||||||
|
assert.match(
|
||||||
|
reportId ?? "",
|
||||||
|
/^[0-9a-f-]{36}$/,
|
||||||
|
"Pass a disposable Linear test issue UUID for webhook verification",
|
||||||
|
);
|
||||||
|
const db = createClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||||
|
process.env.SUPABASE_SECRET_KEY,
|
||||||
|
{ auth: { persistSession: false, autoRefreshToken: false } },
|
||||||
|
);
|
||||||
|
const browser = await chromium.launch({ headless: true }),
|
||||||
|
accounts = [],
|
||||||
|
contexts = [];
|
||||||
|
let roomId,
|
||||||
|
createdReport = false;
|
||||||
|
const checked = (result) => {
|
||||||
|
if (result.error) throw new Error(result.error.message);
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
const phase = async (value) => {
|
||||||
|
await writeFile(
|
||||||
|
".data/support-deploy/appearance-fixture.json",
|
||||||
|
JSON.stringify({ reportId, phase: value }),
|
||||||
|
);
|
||||||
|
console.log("Appearance verification: " + value);
|
||||||
|
};
|
||||||
|
async function until(check, label, timeout = 120000) {
|
||||||
|
const end = Date.now() + timeout;
|
||||||
|
while (Date.now() < end) {
|
||||||
|
if (await check()) return;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
}
|
||||||
|
throw new Error("Timed out: " + label);
|
||||||
|
}
|
||||||
|
async function api(
|
||||||
|
context,
|
||||||
|
path,
|
||||||
|
body,
|
||||||
|
method = body === undefined ? "GET" : "POST",
|
||||||
|
status = 200,
|
||||||
|
) {
|
||||||
|
const response = await context.request.fetch(origin + "/api/v1/" + path, {
|
||||||
|
method,
|
||||||
|
headers: { origin },
|
||||||
|
...(body === undefined ? {} : { data: body }),
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
response.status(),
|
||||||
|
status,
|
||||||
|
path + ": " + (await response.text()),
|
||||||
|
);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
async function account(role) {
|
||||||
|
const email = "appearance-" + randomUUID() + "@example.com",
|
||||||
|
password = randomBytes(24).toString("base64url");
|
||||||
|
const result = checked(
|
||||||
|
await db.auth.admin.createUser({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
email_confirm: true,
|
||||||
|
user_metadata: {
|
||||||
|
display_name:
|
||||||
|
role === "moderator" ? "Prefix moderator test" : "Badge player test",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const id = result.user.id;
|
||||||
|
accounts.push(id);
|
||||||
|
checked(
|
||||||
|
await db.from("profiles").insert({
|
||||||
|
id,
|
||||||
|
role,
|
||||||
|
display_name:
|
||||||
|
role === "moderator" ? "Prefix moderator test" : "Badge player test",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const context = await browser.newContext({ locale: "en-US" });
|
||||||
|
contexts.push(context);
|
||||||
|
await api(context, "auth", {
|
||||||
|
action: "login",
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
next: "/settings",
|
||||||
|
});
|
||||||
|
return { id, context, page: await context.newPage() };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await mkdir(".data/support-deploy", { recursive: true });
|
||||||
|
await phase("starting");
|
||||||
|
const player = await account("contributor"),
|
||||||
|
mod = await account("moderator");
|
||||||
|
await api(player.context, "profile", { glowColor: "#54baff" }, "PATCH", 403);
|
||||||
|
await api(
|
||||||
|
player.context,
|
||||||
|
"profile",
|
||||||
|
{ custom_prefix: "Spoof" },
|
||||||
|
"PATCH",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
await api(player.context, "moderation/users", undefined, "GET", 403);
|
||||||
|
await api(
|
||||||
|
player.context,
|
||||||
|
"moderation/users/" + player.id,
|
||||||
|
{ prefix: "Spoof", icon: "star" },
|
||||||
|
"PATCH",
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
await player.page.goto(origin + "/settings", { waitUntil: "networkidle" });
|
||||||
|
assert.equal(await player.page.locator("input[type=color]").count(), 0);
|
||||||
|
await mod.page.goto(origin + "/settings", { waitUntil: "networkidle" });
|
||||||
|
assert.ok((await mod.page.locator(".username-moderator svg").count()) > 0);
|
||||||
|
await mod.page.goto(origin + "/moderation", { waitUntil: "networkidle" });
|
||||||
|
await mod.page
|
||||||
|
.getByLabel("Player name or account ID", { exact: true })
|
||||||
|
.fill(player.id);
|
||||||
|
await mod.page
|
||||||
|
.getByRole("button", { name: "Find players", exact: true })
|
||||||
|
.click();
|
||||||
|
await mod.page
|
||||||
|
.locator(".prefix-player-list button")
|
||||||
|
.filter({ hasText: player.id })
|
||||||
|
.click();
|
||||||
|
await mod.page
|
||||||
|
.getByLabel("Custom prefix", { exact: true })
|
||||||
|
.fill("Cartographer");
|
||||||
|
await mod.page.getByLabel("Prefix icon", { exact: true }).selectOption("gem");
|
||||||
|
await mod.page
|
||||||
|
.getByRole("button", { name: "Save prefix", exact: true })
|
||||||
|
.click();
|
||||||
|
await until(
|
||||||
|
async () => {
|
||||||
|
const saved = await mod.page
|
||||||
|
.getByText("Player prefix saved.", { exact: true })
|
||||||
|
.count();
|
||||||
|
const alerts = await mod.page.locator("[role=alert]").allTextContents();
|
||||||
|
const visibleErrors = alerts.filter((message) => message.trim());
|
||||||
|
if (visibleErrors.length)
|
||||||
|
throw new Error("Prefix save: " + visibleErrors.join(" | "));
|
||||||
|
return saved > 0;
|
||||||
|
},
|
||||||
|
"prefix save",
|
||||||
|
30000,
|
||||||
|
);
|
||||||
|
await player.page.reload({ waitUntil: "networkidle" });
|
||||||
|
assert.ok(
|
||||||
|
(await player.page
|
||||||
|
.locator(".username-custom-prefix")
|
||||||
|
.filter({ hasText: "Cartographer" })
|
||||||
|
.count()) > 0,
|
||||||
|
);
|
||||||
|
const current = checked(
|
||||||
|
await db.from("profiles").select("*").eq("id", player.id).single(),
|
||||||
|
);
|
||||||
|
assert.equal(current.role, "contributor");
|
||||||
|
assert.equal(current.name_glow, false);
|
||||||
|
assert.equal(current.bug_finder, false);
|
||||||
|
checked(
|
||||||
|
await db.from("bug_reports").insert({
|
||||||
|
id: reportId,
|
||||||
|
client_id: randomUUID(),
|
||||||
|
owner_id: player.id,
|
||||||
|
access_hash: randomBytes(32).toString("hex"),
|
||||||
|
display_name: "Badge player test",
|
||||||
|
title: "Appearance verification",
|
||||||
|
description:
|
||||||
|
"Temporary test report for real webhook badge and glow verification.",
|
||||||
|
state: "open",
|
||||||
|
issue_synced: true,
|
||||||
|
linear_identifier: "Verification",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
createdReport = true;
|
||||||
|
await phase("ready-for-grant");
|
||||||
|
await until(
|
||||||
|
async () => {
|
||||||
|
const row = checked(
|
||||||
|
await db
|
||||||
|
.from("profiles")
|
||||||
|
.select("name_glow,bug_finder")
|
||||||
|
.eq("id", player.id)
|
||||||
|
.single(),
|
||||||
|
);
|
||||||
|
return row.name_glow && row.bug_finder;
|
||||||
|
},
|
||||||
|
"real webhook bug finder grant",
|
||||||
|
240000,
|
||||||
|
);
|
||||||
|
await player.page.reload({ waitUntil: "networkidle" });
|
||||||
|
await player.page.locator("input[type=color]").waitFor();
|
||||||
|
assert.ok(
|
||||||
|
(await player.page.locator(".username-bug-finder svg").count()) > 0,
|
||||||
|
);
|
||||||
|
await player.page.locator("input[type=color]").evaluate((input) => {
|
||||||
|
Object.getOwnPropertyDescriptor(
|
||||||
|
HTMLInputElement.prototype,
|
||||||
|
"value",
|
||||||
|
).set.call(input, "#54baff");
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await player.page
|
||||||
|
.getByRole("button", { name: "Save glow color", exact: true })
|
||||||
|
.click();
|
||||||
|
await player.page.getByText("Glow color saved.", { exact: true }).waitFor();
|
||||||
|
await player.page.reload({ waitUntil: "networkidle" });
|
||||||
|
assert.equal(
|
||||||
|
await player.page.locator("input[type=color]").inputValue(),
|
||||||
|
"#54baff",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await player.page
|
||||||
|
.locator(".account-page-heading .username-glow")
|
||||||
|
.evaluate((element) => getComputedStyle(element).color),
|
||||||
|
"rgb(84, 186, 255)",
|
||||||
|
);
|
||||||
|
const map = checked(
|
||||||
|
await db.from("datasets").select("id").eq("state", "published").limit(1),
|
||||||
|
)[0];
|
||||||
|
assert.ok(map);
|
||||||
|
roomId = (
|
||||||
|
await api(player.context, "rooms", {
|
||||||
|
datasetId: map.id,
|
||||||
|
difficulty: "easy",
|
||||||
|
displayName: "Temporary host alias",
|
||||||
|
playerLimit: 3,
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
const guest = await browser.newContext({ locale: "en-US" });
|
||||||
|
contexts.push(guest);
|
||||||
|
await api(guest, "rooms/" + roomId + "/join", {
|
||||||
|
displayName: "Badge player test",
|
||||||
|
});
|
||||||
|
await api(mod.context, "rooms/" + roomId + "/join", {
|
||||||
|
displayName: "Temporary mod alias",
|
||||||
|
});
|
||||||
|
const room = await api(player.context, "rooms/" + roomId);
|
||||||
|
const host = room.players.find((p) => p.id === room.hostId);
|
||||||
|
assert.equal(host.name, "Badge player test");
|
||||||
|
assert.equal(host.appearance.glow_color, "#54baff");
|
||||||
|
assert.equal(host.appearance.bug_finder, true);
|
||||||
|
assert.equal(host.appearance.custom_prefix, "Cartographer");
|
||||||
|
const guestView = await api(guest, "rooms/" + roomId),
|
||||||
|
guestPlayer = guestView.players.find((p) => p.id === guestView.you);
|
||||||
|
assert.equal(
|
||||||
|
guestPlayer.appearance,
|
||||||
|
undefined,
|
||||||
|
"copying a name cannot copy a badge",
|
||||||
|
);
|
||||||
|
await api(player.context, "rooms/" + roomId + "/chat", {
|
||||||
|
clientId: randomUUID(),
|
||||||
|
text: "Verified colored badge in chat.",
|
||||||
|
});
|
||||||
|
const chat = await api(guest, "rooms/" + roomId + "/chat");
|
||||||
|
assert.equal(chat.messages[0].appearance.bug_finder, true);
|
||||||
|
assert.equal(chat.messages[0].appearance.custom_prefix, "Cartographer");
|
||||||
|
await player.page.goto(origin + "/multiplayer/" + roomId, {
|
||||||
|
waitUntil: "networkidle",
|
||||||
|
});
|
||||||
|
await player.page.getByRole("button", { name: /^Chat/ }).click();
|
||||||
|
await player.page
|
||||||
|
.getByText("Verified colored badge in chat.", { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.ok(
|
||||||
|
(await player.page
|
||||||
|
.locator(".room-chat-log .username-bug-finder svg")
|
||||||
|
.count()) > 0,
|
||||||
|
);
|
||||||
|
await player.page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
assert.ok(
|
||||||
|
await player.page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth <= innerWidth + 1,
|
||||||
|
),
|
||||||
|
"mobile roster overflow",
|
||||||
|
);
|
||||||
|
await player.page.screenshot({
|
||||||
|
path: ".data/support-deploy/appearance-multiplayer-mobile.png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
await player.page.goto(origin + "/settings", { waitUntil: "networkidle" });
|
||||||
|
assert.ok(
|
||||||
|
await player.page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth <= innerWidth + 1,
|
||||||
|
),
|
||||||
|
"mobile settings overflow",
|
||||||
|
);
|
||||||
|
await player.page.screenshot({
|
||||||
|
path: ".data/support-deploy/appearance-settings-mobile.png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
await mod.page
|
||||||
|
.getByRole("button", { name: "Remove prefix", exact: true })
|
||||||
|
.click();
|
||||||
|
await until(
|
||||||
|
async () =>
|
||||||
|
checked(
|
||||||
|
await db
|
||||||
|
.from("profiles")
|
||||||
|
.select("custom_prefix")
|
||||||
|
.eq("id", player.id)
|
||||||
|
.single(),
|
||||||
|
).custom_prefix === null,
|
||||||
|
"prefix removal",
|
||||||
|
);
|
||||||
|
await player.page.reload({ waitUntil: "networkidle" });
|
||||||
|
assert.equal(await player.page.locator(".username-custom-prefix").count(), 0);
|
||||||
|
assert.ok((await player.page.locator(".username-bug-finder").count()) > 0);
|
||||||
|
await phase("ready-for-revoke");
|
||||||
|
await until(
|
||||||
|
async () =>
|
||||||
|
!checked(
|
||||||
|
await db
|
||||||
|
.from("profiles")
|
||||||
|
.select("name_glow")
|
||||||
|
.eq("id", player.id)
|
||||||
|
.single(),
|
||||||
|
).name_glow,
|
||||||
|
"real webhook revoke",
|
||||||
|
240000,
|
||||||
|
);
|
||||||
|
await player.page.reload({ waitUntil: "networkidle" });
|
||||||
|
assert.equal(await player.page.locator("input[type=color]").count(), 0);
|
||||||
|
assert.equal(await player.page.locator(".username-glow").count(), 0);
|
||||||
|
assert.ok((await player.page.locator(".username-bug-finder").count()) > 0);
|
||||||
|
assert.equal(
|
||||||
|
checked(
|
||||||
|
await db
|
||||||
|
.from("profiles")
|
||||||
|
.select("glow_color")
|
||||||
|
.eq("id", player.id)
|
||||||
|
.single(),
|
||||||
|
).glow_color,
|
||||||
|
"#54baff",
|
||||||
|
);
|
||||||
|
await phase("passed");
|
||||||
|
console.log(
|
||||||
|
"Profile appearance production passed: moderator-only prefixes, removal, automatic badges, real webhook grant/revoke, persistent color, authenticated multiplayer/chat, guest spoof prevention and mobile layouts.",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
for (const context of contexts)
|
||||||
|
for (const page of context.pages())
|
||||||
|
if (page.url().includes("/moderation")) {
|
||||||
|
await page
|
||||||
|
.screenshot({
|
||||||
|
path: ".data/support-deploy/appearance-failure.png",
|
||||||
|
fullPage: true,
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
console.error(
|
||||||
|
"Moderator alerts: " +
|
||||||
|
JSON.stringify(
|
||||||
|
await page.locator("[role=alert]").allTextContents(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.error(error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
await phase("failed");
|
||||||
|
} finally {
|
||||||
|
if (roomId)
|
||||||
|
checked(await db.from("multiplayer_rooms").delete().eq("id", roomId));
|
||||||
|
if (createdReport)
|
||||||
|
checked(await db.from("bug_reports").delete().eq("id", reportId));
|
||||||
|
for (const context of contexts)
|
||||||
|
await context.request
|
||||||
|
.post(origin + "/api/v1/auth/logout", { headers: { origin }, data: {} })
|
||||||
|
.catch(() => {});
|
||||||
|
for (const id of accounts) {
|
||||||
|
checked(await db.from("profiles").delete().eq("id", id));
|
||||||
|
checked(await db.auth.admin.deleteUser(id));
|
||||||
|
}
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { glowColorSchema } from "./profile-appearance";
|
||||||
|
|
||||||
export const displayNameSchema = z
|
export const displayNameSchema = z
|
||||||
.string()
|
.string()
|
||||||
@@ -38,8 +39,15 @@ export const authActionSchema = z.discriminatedUnion("action", [
|
|||||||
z.object({ action: z.literal("recover"), email }).strict(),
|
z.object({ action: z.literal("recover"), email }).strict(),
|
||||||
]);
|
]);
|
||||||
export const profileUpdateSchema = z
|
export const profileUpdateSchema = z
|
||||||
.object({ displayName: displayNameSchema })
|
.object({
|
||||||
.strict();
|
displayName: displayNameSchema.optional(),
|
||||||
|
glowColor: glowColorSchema.optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.refine(
|
||||||
|
(value) => value.displayName !== undefined || value.glowColor !== undefined,
|
||||||
|
"Choose a profile change",
|
||||||
|
);
|
||||||
export const initialPasswordSchema = z
|
export const initialPasswordSchema = z
|
||||||
.object({ password: newPasswordSchema })
|
.object({ password: newPasswordSchema })
|
||||||
.strict();
|
.strict();
|
||||||
@@ -61,10 +69,12 @@ export function authDestination(value: string | null | undefined): string {
|
|||||||
value === "/settings" ||
|
value === "/settings" ||
|
||||||
value === "/dashboard" ||
|
value === "/dashboard" ||
|
||||||
value === "/servers" ||
|
value === "/servers" ||
|
||||||
|
value === "/support" ||
|
||||||
value === "/auth/reset-password"
|
value === "/auth/reset-password"
|
||||||
)
|
)
|
||||||
return value;
|
return value;
|
||||||
if (value && /^\/dashboard\/[0-9a-f-]{36}$/i.test(value)) return value;
|
if (value && /^\/dashboard\/[0-9a-f-]{36}$/i.test(value)) return value;
|
||||||
|
if (value && /^\/support\/[0-9a-f-]{36}$/i.test(value)) return value;
|
||||||
if (value && /^\/pair\?code=[A-F0-9]{8}$/.test(value)) return value;
|
if (value && /^\/pair\?code=[A-F0-9]{8}$/.test(value)) return value;
|
||||||
return "/dashboard";
|
return "/dashboard";
|
||||||
}
|
}
|
||||||
|
|||||||
103
shared/bug-reports.ts
Normal file
103
shared/bug-reports.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { displayNameSchema } from "./account";
|
||||||
|
|
||||||
|
export const screenshotLimit = 5 * 1024 * 1024;
|
||||||
|
const text = (min: number, max: number) =>
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(min)
|
||||||
|
.max(max)
|
||||||
|
.refine(
|
||||||
|
(value) =>
|
||||||
|
!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(
|
||||||
|
value,
|
||||||
|
),
|
||||||
|
"Remove hidden control characters",
|
||||||
|
);
|
||||||
|
export const bugReportSchema = z
|
||||||
|
.object({
|
||||||
|
clientId: z.string().uuid(),
|
||||||
|
title: text(5, 120),
|
||||||
|
description: text(15, 6000),
|
||||||
|
displayName: displayNameSchema.optional(),
|
||||||
|
pagePath: z
|
||||||
|
.string()
|
||||||
|
.max(300)
|
||||||
|
.regex(/^\/(?!\/)[a-zA-Z0-9/_-]*$/)
|
||||||
|
.default("/"),
|
||||||
|
screenshot: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
export const bugMessageSchema = z
|
||||||
|
.object({ clientId: z.string().uuid(), text: text(1, 6000) })
|
||||||
|
.strict();
|
||||||
|
export type BugReportRecord = {
|
||||||
|
id: string;
|
||||||
|
client_id: string;
|
||||||
|
owner_id: string | null;
|
||||||
|
access_hash: string;
|
||||||
|
display_name: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
page_path: string;
|
||||||
|
state: "draft" | "pending" | "open";
|
||||||
|
screenshot_expected: boolean;
|
||||||
|
screenshot_path: string | null;
|
||||||
|
linear_asset_url: string | null;
|
||||||
|
linear_identifier: string | null;
|
||||||
|
issue_synced: boolean;
|
||||||
|
glow_requested: boolean;
|
||||||
|
bug_confirmed?: boolean;
|
||||||
|
lease_owner: string | null;
|
||||||
|
lease_until: string | null;
|
||||||
|
retry_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
export type BugMessageRecord = {
|
||||||
|
id: string;
|
||||||
|
client_id: string | null;
|
||||||
|
report_id: string;
|
||||||
|
kind: "user" | "staff" | "system";
|
||||||
|
body: string;
|
||||||
|
author_name: string;
|
||||||
|
delivered: boolean;
|
||||||
|
created_at: string;
|
||||||
|
external_id: string | null;
|
||||||
|
};
|
||||||
|
export type BugReportView = Pick<
|
||||||
|
BugReportRecord,
|
||||||
|
| "id"
|
||||||
|
| "title"
|
||||||
|
| "description"
|
||||||
|
| "page_path"
|
||||||
|
| "state"
|
||||||
|
| "linear_identifier"
|
||||||
|
| "glow_requested"
|
||||||
|
| "created_at"
|
||||||
|
> & {
|
||||||
|
screenshot: string | null;
|
||||||
|
isGuest: boolean;
|
||||||
|
authorAppearance?: import("./profile-appearance").ProfileAppearance;
|
||||||
|
canClaim: boolean;
|
||||||
|
messages: Pick<
|
||||||
|
BugMessageRecord,
|
||||||
|
"id" | "kind" | "body" | "author_name" | "delivered" | "created_at"
|
||||||
|
>[];
|
||||||
|
};
|
||||||
|
export type SupportCommand =
|
||||||
|
| { kind: "reply"; text: string }
|
||||||
|
| { kind: "glow"; enabled: boolean };
|
||||||
|
export function supportCommand(body: string): SupportCommand | null {
|
||||||
|
const reply = /^#player-report-send\s+([\s\S]+)$/.exec(body.trim());
|
||||||
|
if (reply) {
|
||||||
|
const parsed = text(1, 6000).safeParse(reply[1]);
|
||||||
|
return parsed.success ? { kind: "reply", text: parsed.data } : null;
|
||||||
|
}
|
||||||
|
const glow = /^#player-report-glow (grant|revoke)$/.exec(body.trim());
|
||||||
|
return glow ? { kind: "glow", enabled: glow[1] === "grant" } : null;
|
||||||
|
}
|
||||||
|
export function escapeSupportMarkdown(value: string) {
|
||||||
|
return value.replace(/[\\`*_{}\[\]()<>#!|~]/g, "\\$&");
|
||||||
|
}
|
||||||
@@ -43,6 +43,30 @@ export type Database = {
|
|||||||
};
|
};
|
||||||
public: {
|
public: {
|
||||||
Tables: {
|
Tables: {
|
||||||
|
bug_reports: {
|
||||||
|
Row: import("./bug-reports").BugReportRecord;
|
||||||
|
Insert: Partial<import("./bug-reports").BugReportRecord> &
|
||||||
|
Pick<
|
||||||
|
import("./bug-reports").BugReportRecord,
|
||||||
|
| "client_id"
|
||||||
|
| "access_hash"
|
||||||
|
| "display_name"
|
||||||
|
| "title"
|
||||||
|
| "description"
|
||||||
|
>;
|
||||||
|
Update: Partial<import("./bug-reports").BugReportRecord>;
|
||||||
|
Relationships: [];
|
||||||
|
};
|
||||||
|
bug_report_messages: {
|
||||||
|
Row: import("./bug-reports").BugMessageRecord;
|
||||||
|
Insert: Partial<import("./bug-reports").BugMessageRecord> &
|
||||||
|
Pick<
|
||||||
|
import("./bug-reports").BugMessageRecord,
|
||||||
|
"report_id" | "kind" | "body" | "author_name"
|
||||||
|
>;
|
||||||
|
Update: Partial<import("./bug-reports").BugMessageRecord>;
|
||||||
|
Relationships: [];
|
||||||
|
};
|
||||||
multiplayer_messages: {
|
multiplayer_messages: {
|
||||||
Row: {
|
Row: {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -115,6 +139,7 @@ export type Database = {
|
|||||||
};
|
};
|
||||||
multiplayer_players: {
|
multiplayer_players: {
|
||||||
Row: {
|
Row: {
|
||||||
|
account_id: string | null;
|
||||||
id: string;
|
id: string;
|
||||||
room_id: string;
|
room_id: string;
|
||||||
access_hash: string;
|
access_hash: string;
|
||||||
@@ -124,6 +149,7 @@ export type Database = {
|
|||||||
joined_at: string;
|
joined_at: string;
|
||||||
};
|
};
|
||||||
Insert: {
|
Insert: {
|
||||||
|
account_id?: string | null;
|
||||||
id?: string;
|
id?: string;
|
||||||
room_id: string;
|
room_id: string;
|
||||||
access_hash: string;
|
access_hash: string;
|
||||||
@@ -132,7 +158,11 @@ export type Database = {
|
|||||||
has_left?: boolean;
|
has_left?: boolean;
|
||||||
joined_at?: string;
|
joined_at?: string;
|
||||||
};
|
};
|
||||||
Update: { ready_round?: number; has_left?: boolean };
|
Update: {
|
||||||
|
ready_round?: number;
|
||||||
|
has_left?: boolean;
|
||||||
|
account_id?: string | null;
|
||||||
|
};
|
||||||
Relationships: [];
|
Relationships: [];
|
||||||
};
|
};
|
||||||
multiplayer_guesses: {
|
multiplayer_guesses: {
|
||||||
@@ -453,18 +483,33 @@ export type Database = {
|
|||||||
};
|
};
|
||||||
profiles: {
|
profiles: {
|
||||||
Row: {
|
Row: {
|
||||||
|
glow_color: string;
|
||||||
|
bug_finder: boolean;
|
||||||
|
custom_prefix: string | null;
|
||||||
|
custom_prefix_icon: string;
|
||||||
|
name_glow: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
id: string;
|
id: string;
|
||||||
role: string;
|
role: string;
|
||||||
};
|
};
|
||||||
Insert: {
|
Insert: {
|
||||||
|
glow_color?: string;
|
||||||
|
bug_finder?: boolean;
|
||||||
|
custom_prefix?: string | null;
|
||||||
|
custom_prefix_icon?: string;
|
||||||
|
name_glow?: boolean;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
display_name?: string;
|
display_name?: string;
|
||||||
id: string;
|
id: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
};
|
};
|
||||||
Update: {
|
Update: {
|
||||||
|
glow_color?: string;
|
||||||
|
bug_finder?: boolean;
|
||||||
|
custom_prefix?: string | null;
|
||||||
|
custom_prefix_icon?: string;
|
||||||
|
name_glow?: boolean;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
display_name?: string;
|
display_name?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -650,6 +695,33 @@ export type Database = {
|
|||||||
[_ in never]: never;
|
[_ in never]: never;
|
||||||
};
|
};
|
||||||
Functions: {
|
Functions: {
|
||||||
|
set_user_prefix: {
|
||||||
|
Args: {
|
||||||
|
p_reviewer: string;
|
||||||
|
p_user: string;
|
||||||
|
p_prefix: string | null;
|
||||||
|
p_icon: string;
|
||||||
|
};
|
||||||
|
Returns: undefined;
|
||||||
|
};
|
||||||
|
claim_bug_sync: {
|
||||||
|
Args: { p_report: string; p_worker: string };
|
||||||
|
Returns: boolean;
|
||||||
|
};
|
||||||
|
receive_bug_reply: {
|
||||||
|
Args: {
|
||||||
|
p_report: string;
|
||||||
|
p_external: string;
|
||||||
|
p_author: string;
|
||||||
|
p_body: string;
|
||||||
|
p_glow: boolean | null;
|
||||||
|
};
|
||||||
|
Returns: boolean;
|
||||||
|
};
|
||||||
|
claim_bug_report: {
|
||||||
|
Args: { p_report: string; p_hash: string; p_owner: string };
|
||||||
|
Returns: undefined;
|
||||||
|
};
|
||||||
create_map_import: {
|
create_map_import: {
|
||||||
Args: {
|
Args: {
|
||||||
p_owner: string;
|
p_owner: string;
|
||||||
|
|||||||
@@ -5,14 +5,16 @@ export const leaderboardDifficulties = [
|
|||||||
"hard",
|
"hard",
|
||||||
] as const;
|
] as const;
|
||||||
export type LeaderboardDifficulty = (typeof leaderboardDifficulties)[number];
|
export type LeaderboardDifficulty = (typeof leaderboardDifficulties)[number];
|
||||||
export type LeaderboardEntry = {
|
export type LeaderboardEntry =
|
||||||
|
import("./profile-appearance").ProfileAppearance & {
|
||||||
rank: number;
|
rank: number;
|
||||||
player_id: string;
|
player_id: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
|
name_glow?: boolean;
|
||||||
best_score: number;
|
best_score: number;
|
||||||
average_score: number;
|
average_score: number;
|
||||||
games_played: number;
|
games_played: number;
|
||||||
};
|
};
|
||||||
export type LeaderboardData = {
|
export type LeaderboardData = {
|
||||||
entries: LeaderboardEntry[];
|
entries: LeaderboardEntry[];
|
||||||
leaders: LeaderboardEntry[];
|
leaders: LeaderboardEntry[];
|
||||||
|
|||||||
@@ -714,5 +714,77 @@
|
|||||||
"Chat permission requires joining this room": "Чтобы пользоваться чатом, войдите в эту комнату",
|
"Chat permission requires joining this room": "Чтобы пользоваться чатом, войдите в эту комнату",
|
||||||
"Chat is unavailable in a closed or expired room": "Чат недоступен в закрытой комнате или комнате с истёкшим сроком",
|
"Chat is unavailable in a closed or expired room": "Чат недоступен в закрытой комнате или комнате с истёкшим сроком",
|
||||||
"Too many chat messages. Wait a moment and try again.": "Слишком много сообщений. Подождите немного и попробуйте снова.",
|
"Too many chat messages. Wait a moment and try again.": "Слишком много сообщений. Подождите немного и попробуйте снова.",
|
||||||
"Invalid chat message": "Недопустимое сообщение чата"
|
"Invalid chat message": "Недопустимое сообщение чата",
|
||||||
|
"Send bug report": "Сообщить об ошибке",
|
||||||
|
"Report a bug": "Сообщить об ошибке",
|
||||||
|
"Moderator": "Модератор",
|
||||||
|
"Bug finder": "Нашёл ошибку",
|
||||||
|
"Username glow": "Свечение имени",
|
||||||
|
"Choose the color of your glow.": "Выберите цвет свечения вашего имени.",
|
||||||
|
"Support can award a glow for a confirmed bug report.": "Поддержка может подарить свечение за подтверждённую ошибку.",
|
||||||
|
"Glow color": "Цвет свечения",
|
||||||
|
"Save glow color": "Сохранить цвет свечения",
|
||||||
|
"Glow color saved.": "Цвет свечения сохранён.",
|
||||||
|
"Earn a username glow before choosing its color": "Сначала получите свечение имени, чтобы выбрать его цвет",
|
||||||
|
"Choose a valid glow color": "Выберите допустимый цвет свечения",
|
||||||
|
"Player prefixes": "Префиксы игроков",
|
||||||
|
"Find a player and assign a custom prefix with an icon.": "Найдите игрока и выдайте ему свой префикс с иконкой.",
|
||||||
|
"Player name or account ID": "Имя игрока или ID аккаунта",
|
||||||
|
"Find players": "Найти игроков",
|
||||||
|
"No players found.": "Игроки не найдены.",
|
||||||
|
"Custom prefix": "Свой префикс",
|
||||||
|
"Prefix icon": "Иконка префикса",
|
||||||
|
"Save prefix": "Сохранить префикс",
|
||||||
|
"Remove prefix": "Убрать префикс",
|
||||||
|
"Player prefix saved.": "Префикс игрока сохранён.",
|
||||||
|
"Use a prefix of 1–24 characters without hidden controls or markup": "Используйте от 1 до 24 символов без скрытых управляющих символов и разметки",
|
||||||
|
"Star": "Звезда",
|
||||||
|
"Crown": "Корона",
|
||||||
|
"Heart": "Сердце",
|
||||||
|
"Flame": "Огонь",
|
||||||
|
"Gem": "Самоцвет",
|
||||||
|
"Trophy": "Кубок",
|
||||||
|
"Sparkles": "Искры",
|
||||||
|
"Player not found": "Игрок не найден",
|
||||||
|
"PLAYER SUPPORT": "ПОДДЕРЖКА ИГРОКОВ",
|
||||||
|
"Found a bug?": "Нашли ошибку?",
|
||||||
|
"Tell us what happened. We’ll reply here.": "Расскажите, что произошло. Мы ответим здесь.",
|
||||||
|
"Guest reports stay in this browser. Sign in to keep your conversation in your account.": "Обращения гостей сохраняются в этом браузере. Войдите, чтобы сохранить переписку в аккаунте.",
|
||||||
|
"Bug reporting is temporarily unavailable. Please try again later.": "Отправка обращений временно недоступна. Попробуйте позже.",
|
||||||
|
"Your player name": "Ваше имя игрока",
|
||||||
|
"Bug title": "Название ошибки",
|
||||||
|
"What happened?": "Что произошло?",
|
||||||
|
"What did you do, what did you expect, and what happened instead?": "Что вы сделали, чего ожидали и что произошло вместо этого?",
|
||||||
|
"Screenshot (optional)": "Скриншот (необязательно)",
|
||||||
|
"PNG, JPG, or WebP, up to 5 MB. Shared privately with support.": "PNG, JPG или WebP, до 5 МБ. Доступен только вам и поддержке.",
|
||||||
|
"Your bug reports": "Ваши обращения",
|
||||||
|
"Your reports and support replies will appear here.": "Здесь появятся ваши обращения и ответы поддержки.",
|
||||||
|
"Draft": "Черновик",
|
||||||
|
"Waiting for support": "Ожидает поддержки",
|
||||||
|
"← Your bug reports": "← Ваши обращения",
|
||||||
|
"Your report is saved. We’re connecting it to support.": "Обращение сохранено. Передаём его поддержке.",
|
||||||
|
"This report is not sent yet. Return to the form or finish sending it.": "Обращение ещё не отправлено. Вернитесь к форме или завершите отправку.",
|
||||||
|
"Finish sending report": "Завершить отправку",
|
||||||
|
"Keep this report in my account": "Сохранить обращение в аккаунте",
|
||||||
|
"Support awarded you a username glow.": "Поддержка подарила вам свечение имени.",
|
||||||
|
"Account settings": "Настройки аккаунта",
|
||||||
|
"For guest reports, sign in and link this report to receive your reward.": "Если вы отправили обращение как гость, войдите и привяжите его к аккаунту, чтобы получить награду.",
|
||||||
|
"Attached bug screenshot": "Прикреплённый скриншот ошибки",
|
||||||
|
"Support conversation": "Переписка с поддержкой",
|
||||||
|
"Support": "Поддержка",
|
||||||
|
"Delivered to support": "Доставлено поддержке",
|
||||||
|
"Saved · waiting to send": "Сохранено · ожидает отправки",
|
||||||
|
"Message support": "Написать поддержке",
|
||||||
|
"Username glow granted.": "Свечение имени выдано.",
|
||||||
|
"Username glow removed.": "Свечение имени отключено.",
|
||||||
|
"Add a short title, at least 15 characters describing the bug, and your player name.": "Добавьте короткое название, описание ошибки от 15 символов и своё имя игрока.",
|
||||||
|
"Choose a PNG, JPG, or WebP screenshot smaller than 5 MB": "Выберите скриншот PNG, JPG или WebP размером до 5 МБ",
|
||||||
|
"Screenshot upload failed. Please try again.": "Не удалось загрузить скриншот. Попробуйте ещё раз.",
|
||||||
|
"Use 1–6000 characters without hidden controls": "Используйте от 1 до 6000 символов без скрытых управляющих символов",
|
||||||
|
"Open support before sending a report": "Откройте страницу поддержки перед отправкой обращения",
|
||||||
|
"Report not found": "Обращение не найдено",
|
||||||
|
"Submit this report before sending messages": "Отправьте обращение перед отправкой сообщений",
|
||||||
|
"Invalid support message": "Недопустимое сообщение поддержке",
|
||||||
|
"Sign in to link this report": "Войдите, чтобы привязать обращение",
|
||||||
|
"Remove hidden control characters": "Удалите скрытые управляющие символы"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type RoomChat = {
|
|||||||
id: string;
|
id: string;
|
||||||
playerId: string;
|
playerId: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
appearance?: import("./profile-appearance").ProfileAppearance;
|
||||||
text: string;
|
text: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}[];
|
}[];
|
||||||
@@ -62,6 +63,7 @@ export type RoomView = {
|
|||||||
players: {
|
players: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
appearance?: import("./profile-appearance").ProfileAppearance;
|
||||||
left: boolean;
|
left: boolean;
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
submitted: boolean;
|
submitted: boolean;
|
||||||
|
|||||||
46
shared/profile-appearance.ts
Normal file
46
shared/profile-appearance.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
export const defaultGlowColor = "#ffe6a1";
|
||||||
|
export const glowColorSchema = z
|
||||||
|
.string()
|
||||||
|
.regex(/^#[a-fA-F0-9]{6}$/, "Choose a valid glow color")
|
||||||
|
.transform((color) => color.toLowerCase());
|
||||||
|
export const prefixIcons = [
|
||||||
|
"star",
|
||||||
|
"crown",
|
||||||
|
"heart",
|
||||||
|
"flame",
|
||||||
|
"gem",
|
||||||
|
"trophy",
|
||||||
|
"sparkles",
|
||||||
|
] as const;
|
||||||
|
export type PrefixIcon = (typeof prefixIcons)[number];
|
||||||
|
export const customPrefixSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.max(24)
|
||||||
|
.refine(
|
||||||
|
(value) =>
|
||||||
|
!/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069<>]/u.test(value),
|
||||||
|
"Use a prefix of 1–24 characters without hidden controls or markup",
|
||||||
|
);
|
||||||
|
export const prefixUpdateSchema = z
|
||||||
|
.object({ prefix: customPrefixSchema.nullable(), icon: z.enum(prefixIcons) })
|
||||||
|
.strict();
|
||||||
|
export type ProfileAppearance = {
|
||||||
|
name_glow?: boolean;
|
||||||
|
glow_color?: string;
|
||||||
|
role?: string;
|
||||||
|
bug_finder?: boolean;
|
||||||
|
custom_prefix?: string | null;
|
||||||
|
custom_prefix_icon?: string;
|
||||||
|
};
|
||||||
|
export const appearanceColumns =
|
||||||
|
"name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon" as const;
|
||||||
|
export type BadgeUser = ProfileAppearance & {
|
||||||
|
id: string;
|
||||||
|
display_name: string;
|
||||||
|
};
|
||||||
|
export function glowColor(value?: string) {
|
||||||
|
return glowColorSchema.safeParse(value).data ?? defaultGlowColor;
|
||||||
|
}
|
||||||
90
supabase/migrations/20261008071054_bug_reports.sql
Normal file
90
supabase/migrations/20261008071054_bug_reports.sql
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
alter table public.profiles add column name_glow boolean not null default false;
|
||||||
|
create function public.protect_name_glow() returns trigger language plpgsql security invoker set search_path='' as $$
|
||||||
|
begin
|
||||||
|
if ((tg_op='INSERT' and new.name_glow) or (tg_op='UPDATE' and new.name_glow is distinct from old.name_glow))
|
||||||
|
and current_user not in ('postgres','service_role') then raise exception 'Reward permission required'; end if;
|
||||||
|
return new;
|
||||||
|
end; $$;
|
||||||
|
create trigger profiles_protect_glow before insert or update on public.profiles for each row execute function public.protect_name_glow();
|
||||||
|
revoke all on function public.protect_name_glow() from public,anon,authenticated;
|
||||||
|
|
||||||
|
create table public.bug_reports (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
client_id uuid not null unique,
|
||||||
|
owner_id uuid references public.profiles(id) on delete set null,
|
||||||
|
access_hash text not null check(access_hash ~ '^[a-f0-9]{64}$'),
|
||||||
|
display_name text not null,
|
||||||
|
title text not null check(length(title) between 5 and 120),
|
||||||
|
description text not null check(length(description) between 15 and 6000),
|
||||||
|
page_path text not null default '/',
|
||||||
|
state text not null default 'draft' check(state in ('draft','pending','open')),
|
||||||
|
screenshot_expected boolean not null default false,
|
||||||
|
screenshot_path text,
|
||||||
|
linear_asset_url text,
|
||||||
|
linear_identifier text,
|
||||||
|
issue_synced boolean not null default false,
|
||||||
|
glow_requested boolean not null default false,
|
||||||
|
lease_owner text,
|
||||||
|
lease_until timestamptz,
|
||||||
|
retry_at timestamptz,
|
||||||
|
created_at timestamptz not null default clock_timestamp(),
|
||||||
|
updated_at timestamptz not null default clock_timestamp()
|
||||||
|
);
|
||||||
|
create index bug_reports_owner on public.bug_reports(owner_id,created_at desc);
|
||||||
|
create index bug_reports_guest on public.bug_reports(access_hash,created_at desc) where owner_id is null;
|
||||||
|
create table public.bug_report_messages (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
client_id uuid,
|
||||||
|
report_id uuid not null references public.bug_reports(id) on delete cascade,
|
||||||
|
kind text not null check(kind in ('user','staff','system')),
|
||||||
|
body text not null check(length(body) between 1 and 6000),
|
||||||
|
author_name text not null,
|
||||||
|
delivered boolean not null default false,
|
||||||
|
external_id uuid unique,
|
||||||
|
created_at timestamptz not null default clock_timestamp(),
|
||||||
|
unique(report_id,client_id)
|
||||||
|
);
|
||||||
|
create index bug_messages_report on public.bug_report_messages(report_id,created_at,id);
|
||||||
|
alter table public.bug_reports enable row level security;
|
||||||
|
alter table public.bug_report_messages enable row level security;
|
||||||
|
revoke all on public.bug_reports,public.bug_report_messages from public,anon,authenticated;
|
||||||
|
grant all on public.bug_reports,public.bug_report_messages to service_role;
|
||||||
|
|
||||||
|
create function public.claim_bug_sync(p_report uuid,p_worker text) returns boolean language plpgsql security invoker set search_path='' as $$
|
||||||
|
begin
|
||||||
|
update public.bug_reports set lease_owner=p_worker,lease_until=clock_timestamp()+interval '2 minutes'
|
||||||
|
where id=p_report and state<>'draft' and (lease_until is null or lease_until<clock_timestamp())
|
||||||
|
and (retry_at is null or retry_at<clock_timestamp());
|
||||||
|
return found;
|
||||||
|
end; $$;
|
||||||
|
create function public.receive_bug_reply(p_report uuid,p_external uuid,p_author text,p_body text,p_glow boolean default null)
|
||||||
|
returns boolean language plpgsql security invoker set search_path='' as $$
|
||||||
|
declare report public.bug_reports;
|
||||||
|
begin
|
||||||
|
select * into report from public.bug_reports where id=p_report and state<>'draft' for update;
|
||||||
|
if not found then return false; end if;
|
||||||
|
if exists(select 1 from public.bug_report_messages where external_id=p_external) then return false; end if;
|
||||||
|
if p_glow is null and (p_body is null or length(p_body) not between 1 and 6000) then raise exception 'Invalid support reply'; end if;
|
||||||
|
if p_glow is not null then
|
||||||
|
update public.bug_reports set glow_requested=p_glow,updated_at=clock_timestamp() where id=p_report;
|
||||||
|
if report.owner_id is not null then update public.profiles set name_glow=p_glow where id=report.owner_id; end if;
|
||||||
|
end if;
|
||||||
|
insert into public.bug_report_messages(id,report_id,kind,body,author_name,delivered,external_id)
|
||||||
|
values(gen_random_uuid(),p_report,case when p_glow is null then 'staff' else 'system' end,
|
||||||
|
case when p_glow is null then p_body when p_glow then 'Username glow granted.' else 'Username glow removed.' end,
|
||||||
|
left(coalesce(p_author,'Support'),80),true,p_external);
|
||||||
|
return true;
|
||||||
|
end; $$;
|
||||||
|
create function public.claim_bug_report(p_report uuid,p_hash text,p_owner uuid) returns void language plpgsql security invoker set search_path='' as $$
|
||||||
|
declare report public.bug_reports;
|
||||||
|
begin
|
||||||
|
if p_owner is null then raise exception 'Sign in to link this report'; end if;
|
||||||
|
select * into report from public.bug_reports where id=p_report and owner_id is null and access_hash=p_hash for update;
|
||||||
|
if not found then raise exception 'Report not found'; end if;
|
||||||
|
update public.bug_reports set owner_id=p_owner,updated_at=clock_timestamp() where id=p_report;
|
||||||
|
if report.glow_requested then update public.profiles set name_glow=true where id=p_owner; end if;
|
||||||
|
end; $$;
|
||||||
|
revoke all on function public.claim_bug_sync(uuid,text),public.receive_bug_reply(uuid,uuid,text,text,boolean),public.claim_bug_report(uuid,text,uuid) from public,anon,authenticated;
|
||||||
|
grant execute on function public.claim_bug_sync(uuid,text),public.receive_bug_reply(uuid,uuid,text,text,boolean),public.claim_bug_report(uuid,text,uuid) to service_role;
|
||||||
|
insert into storage.buckets(id,name,public,file_size_limit)
|
||||||
|
values('minegeo-support','minegeo-support',false,5242880) on conflict(id) do nothing;
|
||||||
67
supabase/migrations/20261008075322_profile_identity.sql
Normal file
67
supabase/migrations/20261008075322_profile_identity.sql
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
alter table public.profiles
|
||||||
|
add column glow_color text not null default '#ffe6a1' check(glow_color ~ '^#[a-fA-F0-9]{6}$'),
|
||||||
|
add column bug_finder boolean not null default false,
|
||||||
|
add column custom_prefix text check(custom_prefix is null or (length(custom_prefix) between 1 and 24 and custom_prefix !~ '[[:cntrl:]]')),
|
||||||
|
add column custom_prefix_icon text not null default 'star' check(custom_prefix_icon in ('star','crown','heart','flame','gem','trophy','sparkles'));
|
||||||
|
alter table public.bug_reports add column bug_confirmed boolean not null default false;
|
||||||
|
update public.profiles set bug_finder=true where name_glow;
|
||||||
|
update public.bug_reports set bug_confirmed=true where glow_requested;
|
||||||
|
|
||||||
|
create function public.protect_profile_identity() returns trigger language plpgsql security invoker set search_path='' as $$
|
||||||
|
begin
|
||||||
|
if current_user not in ('postgres','service_role') and (
|
||||||
|
(tg_op='INSERT' and (new.role<>'contributor' or new.bug_finder or new.custom_prefix is not null or new.custom_prefix_icon<>'star'))
|
||||||
|
or (tg_op='UPDATE' and (new.role is distinct from old.role or new.bug_finder is distinct from old.bug_finder
|
||||||
|
or new.custom_prefix is distinct from old.custom_prefix or new.custom_prefix_icon is distinct from old.custom_prefix_icon))
|
||||||
|
) then raise exception 'Moderator permission required'; end if;
|
||||||
|
if tg_op='UPDATE' and new.glow_color is distinct from old.glow_color and not new.name_glow then
|
||||||
|
raise exception 'Earn a username glow before choosing its color';
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end; $$;
|
||||||
|
create trigger profiles_protect_identity before insert or update on public.profiles for each row execute function public.protect_profile_identity();
|
||||||
|
revoke all on function public.protect_profile_identity() from public,anon,authenticated;
|
||||||
|
|
||||||
|
create function public.set_user_prefix(p_reviewer uuid,p_user uuid,p_prefix text,p_icon text) returns void
|
||||||
|
language plpgsql security invoker set search_path='' as $$
|
||||||
|
begin
|
||||||
|
if not exists(select 1 from public.profiles where id=p_reviewer and role='moderator') then raise exception 'Moderator permission required'; end if;
|
||||||
|
if p_icon not in ('star','crown','heart','flame','gem','trophy','sparkles') or p_icon is null then raise exception 'Invalid prefix icon'; end if;
|
||||||
|
if p_prefix is not null and (length(btrim(p_prefix)) not between 1 and 24 or p_prefix ~ '[[:cntrl:]]') then raise exception 'Invalid custom prefix'; end if;
|
||||||
|
update public.profiles set custom_prefix=case when p_prefix is null then null else btrim(p_prefix) end,custom_prefix_icon=p_icon where id=p_user;
|
||||||
|
if not found then raise exception 'Player not found'; end if;
|
||||||
|
end; $$;
|
||||||
|
revoke all on function public.set_user_prefix(uuid,uuid,text,text) from public,anon,authenticated;
|
||||||
|
grant execute on function public.set_user_prefix(uuid,uuid,text,text) to service_role;
|
||||||
|
|
||||||
|
create or replace function public.receive_bug_reply(p_report uuid,p_external uuid,p_author text,p_body text,p_glow boolean default null)
|
||||||
|
returns boolean language plpgsql security invoker set search_path='' as $$
|
||||||
|
declare report public.bug_reports;
|
||||||
|
begin
|
||||||
|
select * into report from public.bug_reports where id=p_report and state<>'draft' for update;
|
||||||
|
if not found then return false; end if;
|
||||||
|
if exists(select 1 from public.bug_report_messages where external_id=p_external) then return false; end if;
|
||||||
|
if p_glow is null and (p_body is null or length(p_body) not between 1 and 6000) then raise exception 'Invalid support reply'; end if;
|
||||||
|
if p_glow is not null then
|
||||||
|
update public.bug_reports set glow_requested=p_glow,bug_confirmed=bug_confirmed or p_glow,updated_at=clock_timestamp() where id=p_report;
|
||||||
|
if report.owner_id is not null then update public.profiles set name_glow=p_glow,bug_finder=bug_finder or p_glow where id=report.owner_id; end if;
|
||||||
|
end if;
|
||||||
|
insert into public.bug_report_messages(id,report_id,kind,body,author_name,delivered,external_id)
|
||||||
|
values(gen_random_uuid(),p_report,case when p_glow is null then 'staff' else 'system' end,
|
||||||
|
case when p_glow is null then p_body when p_glow then 'Username glow granted.' else 'Username glow removed.' end,
|
||||||
|
left(coalesce(p_author,'Support'),80),true,p_external);
|
||||||
|
return true;
|
||||||
|
end; $$;
|
||||||
|
create or replace function public.claim_bug_report(p_report uuid,p_hash text,p_owner uuid) returns void
|
||||||
|
language plpgsql security invoker set search_path='' as $$
|
||||||
|
declare report public.bug_reports;
|
||||||
|
begin
|
||||||
|
if p_owner is null then raise exception 'Sign in to link this report'; end if;
|
||||||
|
select * into report from public.bug_reports where id=p_report and owner_id is null and access_hash=p_hash for update;
|
||||||
|
if not found then raise exception 'Report not found'; end if;
|
||||||
|
update public.bug_reports set owner_id=p_owner,updated_at=clock_timestamp() where id=p_report;
|
||||||
|
if report.bug_confirmed then update public.profiles set bug_finder=true,name_glow=name_glow or report.glow_requested where id=p_owner; end if;
|
||||||
|
end; $$;
|
||||||
|
|
||||||
|
alter table public.multiplayer_players add column account_id uuid references public.profiles(id) on delete set null;
|
||||||
|
create index multiplayer_players_account on public.multiplayer_players(account_id) where account_id is not null;
|
||||||
218
tests/bug-reports.test.ts
Normal file
218
tests/bug-reports.test.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { createHmac } from "node:crypto";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import {
|
||||||
|
bugReportSchema,
|
||||||
|
bugMessageSchema,
|
||||||
|
supportCommand,
|
||||||
|
escapeSupportMarkdown,
|
||||||
|
type BugReportRecord,
|
||||||
|
} from "../shared/bug-reports";
|
||||||
|
import { verifySupportWebhook } from "../lib/support-signature";
|
||||||
|
import { supportScreenshot } from "../lib/support-images";
|
||||||
|
import { LinearSupportClient } from "../lib/linear-support-client";
|
||||||
|
const id = "00000000-0000-4000-8000-000000000001",
|
||||||
|
team = "00000000-0000-4000-8000-000000000002";
|
||||||
|
test("reports exclude arbitrary URLs, secrets in page queries, malformed messages and reward privilege fields", () => {
|
||||||
|
const valid = {
|
||||||
|
clientId: id,
|
||||||
|
title: "Wrong map",
|
||||||
|
description: "The map loads with the wrong colors.",
|
||||||
|
pagePath: "/servers",
|
||||||
|
};
|
||||||
|
assert.equal(bugReportSchema.safeParse(valid).success, true);
|
||||||
|
for (const value of [
|
||||||
|
{ ...valid, pagePath: "https://private.example" },
|
||||||
|
{ ...valid, pagePath: "/auth?code=secret" },
|
||||||
|
{ ...valid, pagePath: "//evil" },
|
||||||
|
{ ...valid, name_glow: true },
|
||||||
|
{ ...valid, description: "short" },
|
||||||
|
])
|
||||||
|
assert.equal(bugReportSchema.safeParse(value).success, false);
|
||||||
|
assert.equal(
|
||||||
|
bugMessageSchema.safeParse({
|
||||||
|
clientId: id,
|
||||||
|
text: "#player-report-glow grant",
|
||||||
|
}).success,
|
||||||
|
true,
|
||||||
|
"player text is text, not a staff command",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
bugMessageSchema.safeParse({ clientId: id, text: "x\u202e" }).success,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("only exact staff prefixes publish replies or explicitly change glow", () => {
|
||||||
|
assert.deepEqual(supportCommand("#player-report-send Dear User\nThanks!"), {
|
||||||
|
kind: "reply",
|
||||||
|
text: "Dear User\nThanks!",
|
||||||
|
});
|
||||||
|
assert.deepEqual(supportCommand("#player-report-glow grant"), {
|
||||||
|
kind: "glow",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
assert.deepEqual(supportCommand("#player-report-glow revoke"), {
|
||||||
|
kind: "glow",
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
for (const body of [
|
||||||
|
"Internal staff note",
|
||||||
|
"#user-report-message\n#player-report-send x",
|
||||||
|
"quoted #player-report-send x",
|
||||||
|
"#player-report-glow grant everyone",
|
||||||
|
"#player-report-send",
|
||||||
|
])
|
||||||
|
assert.equal(supportCommand(body), null);
|
||||||
|
assert.ok(
|
||||||
|
escapeSupportMarkdown("#player-report-glow grant").startsWith("\\#"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("webhooks require exact raw-body HMAC and a recent timestamp", () => {
|
||||||
|
const raw = JSON.stringify({ webhookTimestamp: 100_000, body: "Привет" });
|
||||||
|
const sign = createHmac("sha256", "test-secret").update(raw).digest("hex");
|
||||||
|
assert.equal(verifySupportWebhook(raw, sign, "test-secret", 100_001), true);
|
||||||
|
for (const [body, sig, key, time] of [
|
||||||
|
[raw + " ", sign, "test-secret", 100_001],
|
||||||
|
[raw, "ff", "test-secret", 100_001],
|
||||||
|
[raw, sign, "wrong", 100_001],
|
||||||
|
[raw, sign, "test-secret", 160_001],
|
||||||
|
[raw, sign, "test-secret", 39_999],
|
||||||
|
])
|
||||||
|
assert.equal(
|
||||||
|
verifySupportWebhook(
|
||||||
|
body as string,
|
||||||
|
sig as string,
|
||||||
|
key as string,
|
||||||
|
time as number,
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("screenshots reject disguised documents and oversized files, strip metadata and normalize images", async () => {
|
||||||
|
const png = await sharp({
|
||||||
|
create: { width: 50, height: 30, channels: 3, background: "#abcdef" },
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
const webp = await supportScreenshot(png);
|
||||||
|
const meta = await sharp(webp).metadata();
|
||||||
|
assert.equal(meta.format, "webp");
|
||||||
|
assert.equal(meta.width, 50);
|
||||||
|
assert.equal(meta.exif, undefined);
|
||||||
|
await assert.rejects(
|
||||||
|
supportScreenshot(
|
||||||
|
Buffer.from(
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"></svg>',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
/PNG, JPG, or WebP/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
supportScreenshot(new Uint8Array(5 * 1024 * 1024 + 1)),
|
||||||
|
/smaller than 5 MB/,
|
||||||
|
);
|
||||||
|
await assert.rejects(supportScreenshot(Buffer.from("not an image")));
|
||||||
|
});
|
||||||
|
test("Linear sends stable issue/comment UUIDs and recovers a timed-out creation without duplicates", async () => {
|
||||||
|
let issue: unknown = null,
|
||||||
|
comment: unknown = null,
|
||||||
|
creates = 0,
|
||||||
|
comments = 0;
|
||||||
|
const bodies: unknown[] = [];
|
||||||
|
const request = (async (_url, init) => {
|
||||||
|
const body = JSON.parse(String(init?.body));
|
||||||
|
bodies.push(body);
|
||||||
|
if (body.query.startsWith("query SupportIssue"))
|
||||||
|
return Response.json(
|
||||||
|
issue
|
||||||
|
? { data: { issue } }
|
||||||
|
: {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
message: "Entity not found",
|
||||||
|
extensions: { code: "ENTITY_NOT_FOUND" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (body.query.includes("issueCreate")) {
|
||||||
|
creates++;
|
||||||
|
assert.equal(body.variables.input.id, id);
|
||||||
|
issue = { id, identifier: "TEST-1", team: { id: team } };
|
||||||
|
throw new Error("Response lost");
|
||||||
|
}
|
||||||
|
if (body.query.startsWith("query SupportComment"))
|
||||||
|
return Response.json(
|
||||||
|
comment
|
||||||
|
? { data: { comment } }
|
||||||
|
: { errors: [{ message: "Entity not found" }] },
|
||||||
|
);
|
||||||
|
if (body.query.includes("commentCreate")) {
|
||||||
|
comments++;
|
||||||
|
comment = { id: body.variables.input.id, issue: { id } };
|
||||||
|
return Response.json({
|
||||||
|
data: { commentCreate: { success: true, comment } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error("Unexpected query");
|
||||||
|
}) as typeof fetch;
|
||||||
|
const client = new LinearSupportClient("test-key", team, "P-TEST-1", request);
|
||||||
|
const report = {
|
||||||
|
id,
|
||||||
|
title: "Test issue",
|
||||||
|
display_name: "Player",
|
||||||
|
description: "#player-report-glow grant",
|
||||||
|
page_path: "/",
|
||||||
|
linear_asset_url: null,
|
||||||
|
} as BugReportRecord;
|
||||||
|
assert.equal((await client.createIssue(report)).identifier, "TEST-1");
|
||||||
|
await client.createIssue(report);
|
||||||
|
assert.equal(creates, 1);
|
||||||
|
await client.comment(team, id, "Player", "#player-report-send Spoof");
|
||||||
|
await client.comment(team, id, "Player", "#player-report-send Spoof");
|
||||||
|
assert.equal(comments, 1);
|
||||||
|
const mutation = (
|
||||||
|
bodies as {
|
||||||
|
variables: { input?: { body?: string; description?: string } };
|
||||||
|
}[]
|
||||||
|
).find((body) => body.variables.input?.body);
|
||||||
|
assert.ok(
|
||||||
|
mutation?.variables.input?.body?.startsWith("#user-report-message\n"),
|
||||||
|
);
|
||||||
|
assert.ok(mutation?.variables.input?.body?.includes("\\#player-report-send"));
|
||||||
|
});
|
||||||
|
test("Linear partial GraphQL failures and screenshot PUT failures are never accepted as delivered", async () => {
|
||||||
|
const client = new LinearSupportClient(
|
||||||
|
"test-key",
|
||||||
|
team,
|
||||||
|
undefined,
|
||||||
|
async () =>
|
||||||
|
Response.json({ data: { issue: {} }, errors: [{ message: "Denied" }] }),
|
||||||
|
);
|
||||||
|
await assert.rejects(client.issue(id), /temporarily unavailable/);
|
||||||
|
const upload = new LinearSupportClient(
|
||||||
|
"test-key",
|
||||||
|
team,
|
||||||
|
undefined,
|
||||||
|
async (url) =>
|
||||||
|
String(url).includes("graphql")
|
||||||
|
? Response.json({
|
||||||
|
data: {
|
||||||
|
fileUpload: {
|
||||||
|
success: true,
|
||||||
|
uploadFile: {
|
||||||
|
uploadUrl: "https://upload.test/file",
|
||||||
|
assetUrl: "https://asset.test/file",
|
||||||
|
headers: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: new Response(null, { status: 403 }),
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
upload.upload(new Uint8Array([1]), "test.webp"),
|
||||||
|
/screenshot upload failed/,
|
||||||
|
);
|
||||||
|
});
|
||||||
47
tests/profile-appearance.test.ts
Normal file
47
tests/profile-appearance.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { profileUpdateSchema } from "../shared/account";
|
||||||
|
import {
|
||||||
|
prefixUpdateSchema,
|
||||||
|
glowColor,
|
||||||
|
defaultGlowColor,
|
||||||
|
} from "../shared/profile-appearance";
|
||||||
|
test("players may choose a hex glow color but cannot assign badges, roles or rewards", () => {
|
||||||
|
assert.deepEqual(profileUpdateSchema.parse({ glowColor: "#12ABff" }), {
|
||||||
|
glowColor: "#12abff",
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
profileUpdateSchema.safeParse({ displayName: "Pavel" }).success,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
for (const body of [
|
||||||
|
{},
|
||||||
|
{ role: "moderator" },
|
||||||
|
{ bug_finder: true },
|
||||||
|
{ name_glow: true },
|
||||||
|
{ custom_prefix: "Admin" },
|
||||||
|
{ glowColor: "red; background:url(https://example.com)" },
|
||||||
|
{ glowColor: "#fff" },
|
||||||
|
])
|
||||||
|
assert.equal(profileUpdateSchema.safeParse(body).success, false);
|
||||||
|
assert.equal(glowColor("url(example.com)"), defaultGlowColor);
|
||||||
|
});
|
||||||
|
test("custom prefixes are bounded plain text with a known icon and support explicit removal", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
prefixUpdateSchema.parse({ prefix: " Map artist ", icon: "gem" }),
|
||||||
|
{ prefix: "Map artist", icon: "gem" },
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
prefixUpdateSchema.safeParse({ prefix: null, icon: "star" }).success,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
for (const body of [
|
||||||
|
{ prefix: "", icon: "star" },
|
||||||
|
{ prefix: "x".repeat(25), icon: "star" },
|
||||||
|
{ prefix: "<script>", icon: "star" },
|
||||||
|
{ prefix: "hi\u202e", icon: "star" },
|
||||||
|
{ prefix: "Hi", icon: "https://example.com" },
|
||||||
|
{ prefix: "Hi", icon: "star", role: "moderator" },
|
||||||
|
])
|
||||||
|
assert.equal(prefixUpdateSchema.safeParse(body).success, false);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user