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.
|
||||
MINEGEO_RENDER_BACKEND=auto
|
||||
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 Link from "next/link";
|
||||
import { accountProfile } from "@/lib/profile";
|
||||
import { Username } from "@/components/Username";
|
||||
export const dynamic = "force-dynamic";
|
||||
export default async function DashboardPage() {
|
||||
const { t, formatDate } = await getI18n();
|
||||
@@ -33,7 +34,12 @@ export default async function DashboardPage() {
|
||||
<span className="eyebrow">{t("THE CARTOGRAPHER’S DESK")}</span>
|
||||
<h1>{t("My expedition.")}</h1>
|
||||
<p className="lead">
|
||||
{t("Welcome back,")} {profile?.display_name}.
|
||||
{t("Welcome back,")}{" "}
|
||||
<Username
|
||||
name={profile?.display_name ?? "Explorer"}
|
||||
appearance={profile ?? undefined}
|
||||
/>
|
||||
.
|
||||
</p>
|
||||
<Dashboard />
|
||||
{history.length > 0 && (
|
||||
|
||||
@@ -15,6 +15,7 @@ import { getI18n } from "@/lib/i18n";
|
||||
import "./polish.css";
|
||||
import "./mobile-game.css";
|
||||
import "./multiplayer.css";
|
||||
import "./support.css";
|
||||
import { SiteSettingsProvider } from "@/components/SiteSettingsProvider";
|
||||
import { initialSiteSettings } from "@/lib/site-settings";
|
||||
import { user } from "@/lib/supabase";
|
||||
|
||||
@@ -286,7 +286,7 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
.room-chat-log strong,
|
||||
.room-chat-log span {
|
||||
.room-chat-log p > span {
|
||||
display: block;
|
||||
}
|
||||
.room-chat-log strong {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getI18n } from "@/lib/i18n";
|
||||
import { Username } from "@/components/Username";
|
||||
import { notFound } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { Header, Footer } from "@/components/Shell";
|
||||
@@ -31,7 +32,8 @@ export default async function ServerPage({
|
||||
<h1>{map.name}</h1>
|
||||
<p className="lead">{map.description}</p>
|
||||
<p className="map-credit">
|
||||
{t("Created by")} {map.credit}
|
||||
{t("Created by")}{" "}
|
||||
<Username name={map.credit} appearance={map.creditAppearance} />
|
||||
</p>
|
||||
<ReportContent datasetId={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 { user } from "@/lib/supabase";
|
||||
import { accountProfile } from "@/lib/profile";
|
||||
import { Username } from "@/components/Username";
|
||||
export async function generateMetadata() {
|
||||
const { t } = await getI18n();
|
||||
return { title: t("Account settings") };
|
||||
@@ -32,12 +33,14 @@ export default async function Settings({
|
||||
<span className="eyebrow">{t("YOUR CARTOGRAPHER ACCOUNT")}</span>
|
||||
<h1>{t("Account settings.")}</h1>
|
||||
<p className="lead">
|
||||
<Username name={profile.display_name} appearance={profile} /> ·{" "}
|
||||
{t("Your name, your sign-in, your next expedition.")}
|
||||
</p>
|
||||
</div>
|
||||
<AccountSettings
|
||||
email={account.email ?? ""}
|
||||
displayName={profile.display_name}
|
||||
appearance={profile}
|
||||
passwordUpdated={query.password === "updated"}
|
||||
/>
|
||||
</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 { PasswordForm } from "./PasswordForm";
|
||||
import { displayNameSchema } from "@/shared/account";
|
||||
import {
|
||||
defaultGlowColor,
|
||||
type ProfileAppearance,
|
||||
} from "@/shared/profile-appearance";
|
||||
import { Username } from "./Username";
|
||||
export function AccountSettings({
|
||||
email,
|
||||
displayName,
|
||||
appearance,
|
||||
passwordUpdated = false,
|
||||
}: {
|
||||
email: string;
|
||||
displayName: string;
|
||||
appearance: ProfileAppearance;
|
||||
passwordUpdated?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
@@ -25,6 +32,25 @@ export function AccountSettings({
|
||||
[recoveryError, setRecoveryError] = useState(""),
|
||||
[sending, setSending] = useState(false);
|
||||
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) {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
@@ -119,6 +145,49 @@ export function AccountSettings({
|
||||
</button>
|
||||
</form>
|
||||
</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">
|
||||
<div className="settings-heading">
|
||||
<LockKeyhole size={22} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useI18n } from "@/components/I18nProvider";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { PixelTrophy, PlayerAvatar } from "./PixelArt";
|
||||
import { Username } from "./Username";
|
||||
import {
|
||||
type LeaderboardData,
|
||||
type LeaderboardEntry,
|
||||
@@ -105,7 +106,7 @@ export function LeaderboardTable({
|
||||
<div className="leaderboard-player">
|
||||
<PlayerAvatar id={entry.player_id} />
|
||||
<span className="player-name">
|
||||
{entry.display_name}
|
||||
<Username name={entry.display_name} appearance={entry} />
|
||||
{entry.player_id === playerId && (
|
||||
<span className="you-label">{t("You")}</span>
|
||||
)}
|
||||
@@ -152,7 +153,9 @@ export function WinnersPodium({ leaders }: { leaders: LeaderboardEntry[] }) {
|
||||
})}
|
||||
</span>
|
||||
<PlayerAvatar id={entry.player_id} />
|
||||
<h2>{entry.display_name}</h2>
|
||||
<h2>
|
||||
<Username name={entry.display_name} appearance={entry} />
|
||||
</h2>
|
||||
<p>
|
||||
{formatNumber(entry.best_score)} <span>/ 25,000</span>
|
||||
</p>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useI18n } from "@/components/I18nProvider";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ArrowUpRight, MapPin } from "lucide-react";
|
||||
import { Username } from "./Username";
|
||||
export type CardMap = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -11,6 +12,8 @@ export type CardMap = {
|
||||
panorama_count: number;
|
||||
image?: string | null;
|
||||
credit?: string;
|
||||
creditGlow?: boolean;
|
||||
creditAppearance?: import("@/shared/profile-appearance").ProfileAppearance;
|
||||
server?: { name: string } | undefined;
|
||||
bounds: { minX: number; minZ: number; maxX: number; maxZ: number };
|
||||
};
|
||||
@@ -51,7 +54,12 @@ export function MapCard({
|
||||
<p>{map.description}</p>
|
||||
{map.credit && (
|
||||
<p className="map-credit">
|
||||
{t("By")} {map.credit}
|
||||
{t("By")}{" "}
|
||||
<Username
|
||||
name={map.credit}
|
||||
appearance={map.creditAppearance}
|
||||
glow={map.creditGlow}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
<div className="card-meta">
|
||||
|
||||
@@ -9,6 +9,7 @@ import { PanoramaPreview } from "./PanoramaPreview";
|
||||
import { reportCategories } from "@/shared/moderation";
|
||||
import type { Appeal } from "./MapAppeal";
|
||||
import { ModeratorSettings } from "./ModeratorSettings";
|
||||
import { ModeratorPrefixes } from "./ModeratorPrefixes";
|
||||
type ReviewMap = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -114,6 +115,7 @@ export function Moderation() {
|
||||
return (
|
||||
<>
|
||||
{authorized && <ModeratorSettings />}
|
||||
{authorized && <ModeratorPrefixes />}
|
||||
{error && (
|
||||
<p role="alert" className="error-message">
|
||||
{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";
|
||||
import { Username } from "./Username";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
@@ -139,7 +140,7 @@ export function MultiplayerGame({
|
||||
{room.players.map((player) => (
|
||||
<li key={player.id}>
|
||||
<span>
|
||||
{player.name}
|
||||
<Username name={player.name} appearance={player.appearance} />
|
||||
{player.id === room.you && <small> {t("(you)")}</small>}
|
||||
<small>
|
||||
{player.left
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useI18n } from "./I18nProvider";
|
||||
import { api } from "@/lib/client-api";
|
||||
import { roundClock } from "@/shared/round-clock";
|
||||
import type { RoomView } from "@/shared/multiplayer";
|
||||
import { Username } from "./Username";
|
||||
|
||||
export function MultiplayerRoom({ id }: { id: string }) {
|
||||
const { t, formatNumber } = useI18n();
|
||||
@@ -247,7 +248,10 @@ export function MultiplayerRoom({ id }: { id: string }) {
|
||||
{player.id === room.hostId && (
|
||||
<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>}
|
||||
</span>
|
||||
{owner && player.id !== room.you && (
|
||||
@@ -304,7 +308,16 @@ export function MultiplayerRoom({ id }: { id: string }) {
|
||||
<>
|
||||
<p className="lead">
|
||||
<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", {
|
||||
count: formatNumber(winners[0]?.total ?? 0),
|
||||
})}
|
||||
@@ -334,7 +347,10 @@ export function MultiplayerRoom({ id }: { id: string }) {
|
||||
>
|
||||
<td>{player.rank}</td>
|
||||
<td>
|
||||
{player.name}
|
||||
<Username
|
||||
name={player.name}
|
||||
appearance={player.appearance}
|
||||
/>
|
||||
{player.left && <small> · {t("Left room")}</small>}
|
||||
</td>
|
||||
{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";
|
||||
import { Username } from "./Username";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { MessageCircle, Send, X } from "lucide-react";
|
||||
@@ -197,7 +198,7 @@ export function RoomChat({
|
||||
className={message.playerId === playerId ? "room-chat-mine" : ""}
|
||||
>
|
||||
<strong>
|
||||
{message.name}
|
||||
<Username name={message.name} appearance={message.appearance} />
|
||||
{message.playerId === playerId && ` ${t("(you)")}`}
|
||||
</strong>
|
||||
<span>{message.text}</span>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { LanguageSwitcher } from "./LanguageSwitcher";
|
||||
import { user } from "@/lib/supabase";
|
||||
import { Brand } from "./Brand";
|
||||
import { SiteNav } from "./SiteNav";
|
||||
import { ReportBugButton } from "./ReportBugButton";
|
||||
export async function Header() {
|
||||
const { t } = await getI18n();
|
||||
const account = await user();
|
||||
@@ -14,12 +15,15 @@ export async function Header() {
|
||||
<div className="header-inner">
|
||||
<Brand />
|
||||
<SiteNav />
|
||||
<Link
|
||||
href={account ? "/dashboard" : "/log-in"}
|
||||
className="header-account"
|
||||
>
|
||||
{account ? t("My account") : t("Log in")} <ArrowUpRight size={15} />
|
||||
</Link>
|
||||
<div className="header-actions">
|
||||
<ReportBugButton />
|
||||
<Link
|
||||
href={account ? "/dashboard" : "/log-in"}
|
||||
className="header-account"
|
||||
>
|
||||
{account ? t("My account") : t("Log in")} <ArrowUpRight size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</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
|
||||
`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") {
|
||||
const body = profileUpdateSchema.parse(await context.jsonBody(request));
|
||||
await accountProfile(account);
|
||||
const current = await accountProfile(account);
|
||||
if (body.glowColor !== undefined && !current.name_glow)
|
||||
return json(
|
||||
{ error: "Earn a username glow before choosing its color" },
|
||||
403,
|
||||
);
|
||||
const profile = required(
|
||||
await admin()
|
||||
.from("profiles")
|
||||
.update({ display_name: body.displayName })
|
||||
.update({
|
||||
...(body.displayName !== undefined
|
||||
? { display_name: body.displayName }
|
||||
: {}),
|
||||
...(body.glowColor !== undefined
|
||||
? { glow_color: body.glowColor }
|
||||
: {}),
|
||||
})
|
||||
.eq("id", account.id)
|
||||
.select("id,display_name,role")
|
||||
.select(
|
||||
"id,display_name,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon",
|
||||
)
|
||||
.single(),
|
||||
);
|
||||
return json({ profile });
|
||||
|
||||
@@ -16,6 +16,8 @@ import { ServiceError, checkServiceError } from "./service-error";
|
||||
import { requestOrigin, verifyOrigin } from "./request-origin";
|
||||
import { accountApi } from "./account-api";
|
||||
import { multiplayerApi } from "./multiplayer-api";
|
||||
import { bugReportApi } from "./bug-report-api";
|
||||
import { moderatorUsersApi } from "./moderator-users-api";
|
||||
import { contributionCutoff, reportSchema } from "../shared/moderation";
|
||||
import { siteSettingsSchema } from "../shared/site-settings";
|
||||
import { getSiteSettings } from "./site-settings";
|
||||
@@ -119,6 +121,8 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
return await accountApi(request, path, { jsonBody, limit });
|
||||
if (path[0] === "rooms")
|
||||
return await multiplayerApi(request, path, { jsonBody, limit });
|
||||
if (path[0] === "bug-reports")
|
||||
return await bugReportApi(request, path, { jsonBody, limit });
|
||||
const client = admin(),
|
||||
method = request.method;
|
||||
if (path[0] === "site-settings" && path.length === 1 && method === "GET") {
|
||||
@@ -900,6 +904,8 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
}
|
||||
if (path[0] === "moderation") {
|
||||
const reviewer = await moderator(request);
|
||||
if (path[1] === "users")
|
||||
return await moderatorUsersApi(request, path, reviewer, jsonBody);
|
||||
if (path[1] === "settings" && path.length === 2 && method === "PATCH") {
|
||||
const body = siteSettingsSchema.parse(await jsonBody(request));
|
||||
const enabled = required(
|
||||
|
||||
304
lib/bug-report-api.ts
Normal file
304
lib/bug-report-api.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import "server-only";
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { admin, required, user } from "./supabase";
|
||||
import { accountProfile } from "./profile";
|
||||
import { requestOrigin } from "./request-origin";
|
||||
import { ServiceError } from "./service-error";
|
||||
import {
|
||||
bugReportSchema,
|
||||
bugMessageSchema,
|
||||
type BugReportRecord,
|
||||
type BugReportView,
|
||||
} from "../shared/bug-reports";
|
||||
import { supportScreenshot } from "./support-images";
|
||||
import { supportReady, syncBugAfterResponse } from "./support-sync";
|
||||
|
||||
type Dependencies = {
|
||||
jsonBody: (request: NextRequest) => Promise<unknown>;
|
||||
limit: (key: string, count: number, seconds: number) => Promise<void>;
|
||||
};
|
||||
export async function bugReportApi(
|
||||
request: NextRequest,
|
||||
path: string[],
|
||||
{ jsonBody, limit }: Dependencies,
|
||||
) {
|
||||
const db = admin(),
|
||||
account = await user();
|
||||
const profile = account ? await accountProfile(account) : null;
|
||||
const saved = request.cookies.get("mg_support")?.value;
|
||||
const secret = saved ?? randomBytes(32).toString("hex");
|
||||
const hash = createHash("sha256").update(secret).digest("hex");
|
||||
const respond = (body: unknown) => {
|
||||
const response = NextResponse.json(body, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
if (!saved)
|
||||
response.cookies.set("mg_support", secret, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: requestOrigin(request, process.env.MINEGEO_URL).startsWith(
|
||||
"https:",
|
||||
),
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 180,
|
||||
});
|
||||
return response;
|
||||
};
|
||||
const owns = (record: BugReportRecord) =>
|
||||
record.owner_id
|
||||
? record.owner_id === account?.id
|
||||
: !!saved && record.access_hash === hash;
|
||||
async function owned(id: string) {
|
||||
const record = required(
|
||||
await db.from("bug_reports").select("*").eq("id", id).maybeSingle(),
|
||||
);
|
||||
if (!owns(record)) throw new ServiceError("Report not found", 404);
|
||||
return record;
|
||||
}
|
||||
async function view(record: BugReportRecord): Promise<BugReportView> {
|
||||
const messages = required(
|
||||
await db
|
||||
.from("bug_report_messages")
|
||||
.select("id,kind,body,author_name,delivered,created_at")
|
||||
.eq("report_id", record.id)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(200),
|
||||
).reverse();
|
||||
const screenshot = record.screenshot_path
|
||||
? required(
|
||||
await db.storage
|
||||
.from("minegeo-support")
|
||||
.createSignedUrl(record.screenshot_path, 600),
|
||||
).signedUrl
|
||||
: null;
|
||||
return {
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
description: record.description,
|
||||
page_path: record.page_path,
|
||||
state: record.state,
|
||||
linear_identifier: record.linear_identifier,
|
||||
glow_requested: record.glow_requested,
|
||||
created_at: record.created_at,
|
||||
screenshot,
|
||||
messages,
|
||||
isGuest: record.owner_id === null,
|
||||
authorAppearance: record.owner_id ? (profile ?? undefined) : undefined,
|
||||
canClaim: record.owner_id === null && !!account,
|
||||
};
|
||||
}
|
||||
if (path.length === 1 && request.method === "GET") {
|
||||
const rows = required(
|
||||
await db
|
||||
.from("bug_reports")
|
||||
.select("id,title,state,created_at,linear_identifier")
|
||||
.or(
|
||||
account
|
||||
? `owner_id.eq.${account.id},and(owner_id.is.null,access_hash.eq.${hash})`
|
||||
: `and(owner_id.is.null,access_hash.eq.${hash})`,
|
||||
)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(50),
|
||||
);
|
||||
return respond({
|
||||
ready: supportReady(),
|
||||
signedIn: !!account,
|
||||
displayName: profile?.display_name ?? null,
|
||||
nameGlow: profile?.name_glow ?? false,
|
||||
reports: rows,
|
||||
});
|
||||
}
|
||||
if (request.method === "POST" && path.length === 1) {
|
||||
if (!saved)
|
||||
throw new ServiceError("Open support before sending a report", 409);
|
||||
const body = bugReportSchema.parse(await jsonBody(request));
|
||||
const lookup = await db
|
||||
.from("bug_reports")
|
||||
.select("*")
|
||||
.eq("client_id", body.clientId)
|
||||
.maybeSingle();
|
||||
if (lookup.error) required(lookup);
|
||||
let record = lookup.data;
|
||||
if (record && !owns(record))
|
||||
throw new ServiceError("Report not found", 404);
|
||||
if (!record) {
|
||||
if (!supportReady())
|
||||
throw new ServiceError(
|
||||
"Bug reporting is temporarily unavailable. Please try again later.",
|
||||
503,
|
||||
);
|
||||
const ip =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0].trim() ??
|
||||
"unknown";
|
||||
await limit(
|
||||
"support:create:" + createHash("sha256").update(ip).digest("hex"),
|
||||
20,
|
||||
3600,
|
||||
);
|
||||
await limit("support:create:" + hash, 5, 3600);
|
||||
const inserted = await db
|
||||
.from("bug_reports")
|
||||
.upsert(
|
||||
{
|
||||
id: randomUUID(),
|
||||
client_id: body.clientId,
|
||||
owner_id: account?.id ?? null,
|
||||
access_hash: hash,
|
||||
display_name:
|
||||
profile?.display_name ?? body.displayName ?? "Guest explorer",
|
||||
title: body.title,
|
||||
description: body.description,
|
||||
page_path: body.pagePath,
|
||||
screenshot_expected: body.screenshot,
|
||||
},
|
||||
{ onConflict: "client_id", ignoreDuplicates: true },
|
||||
)
|
||||
.select("id");
|
||||
required(inserted);
|
||||
record = required(
|
||||
await db
|
||||
.from("bug_reports")
|
||||
.select("*")
|
||||
.eq("client_id", body.clientId)
|
||||
.single(),
|
||||
);
|
||||
if (!owns(record)) throw new ServiceError("Report not found", 404);
|
||||
}
|
||||
const upload =
|
||||
record.state === "draft" && record.screenshot_expected
|
||||
? required(
|
||||
await db.storage
|
||||
.from("minegeo-support")
|
||||
.createSignedUploadUrl(`${record.id}/input`, { upsert: true }),
|
||||
).signedUrl
|
||||
: null;
|
||||
return respond({ id: record.id, state: record.state, upload });
|
||||
}
|
||||
const id = z.string().uuid().parse(path[1]);
|
||||
const record = await owned(id);
|
||||
if (path.length === 2 && request.method === "GET") {
|
||||
if (record.state !== "draft") syncBugAfterResponse(id);
|
||||
return respond(await view(record));
|
||||
}
|
||||
if (path.length !== 3 || request.method !== "POST")
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
if (path[2] === "submit") {
|
||||
if (record.state === "draft") {
|
||||
let screenshotPath: string | null = null;
|
||||
if (record.screenshot_expected) {
|
||||
const image = required(
|
||||
await db.storage.from("minegeo-support").download(`${id}/input`),
|
||||
);
|
||||
const normalized = await supportScreenshot(
|
||||
new Uint8Array(await image.arrayBuffer()),
|
||||
);
|
||||
screenshotPath = `${id}/screenshot.webp`;
|
||||
const uploaded = await db.storage
|
||||
.from("minegeo-support")
|
||||
.upload(screenshotPath, normalized, {
|
||||
contentType: "image/webp",
|
||||
upsert: false,
|
||||
});
|
||||
if (
|
||||
uploaded.error &&
|
||||
uploaded.error.message !== "The resource already exists"
|
||||
)
|
||||
throw new ServiceError(
|
||||
"Screenshot upload failed. Please try again.",
|
||||
503,
|
||||
);
|
||||
}
|
||||
// Stable initial message ID and conditional transition make submit retries safe.
|
||||
required(
|
||||
await db
|
||||
.from("bug_report_messages")
|
||||
.upsert(
|
||||
{
|
||||
id,
|
||||
report_id: id,
|
||||
kind: "user",
|
||||
body: record.description,
|
||||
author_name: record.display_name,
|
||||
},
|
||||
{ onConflict: "id", ignoreDuplicates: true },
|
||||
)
|
||||
.select("id"),
|
||||
);
|
||||
required(
|
||||
await db
|
||||
.from("bug_reports")
|
||||
.update({
|
||||
state: "pending",
|
||||
screenshot_path: screenshotPath,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", id)
|
||||
.eq("state", "draft")
|
||||
.select("id"),
|
||||
);
|
||||
}
|
||||
syncBugAfterResponse(id);
|
||||
return respond(await view(await owned(id)));
|
||||
}
|
||||
if (path[2] === "messages") {
|
||||
if (record.state === "draft")
|
||||
throw new ServiceError("Submit this report before sending messages", 409);
|
||||
const body = bugMessageSchema.parse(await jsonBody(request));
|
||||
const existing = await db
|
||||
.from("bug_report_messages")
|
||||
.select("report_id,kind")
|
||||
.eq("report_id", id)
|
||||
.eq("client_id", body.clientId)
|
||||
.maybeSingle();
|
||||
if (existing.error) required(existing);
|
||||
if (
|
||||
existing.data &&
|
||||
(existing.data.report_id !== id || existing.data.kind !== "user")
|
||||
)
|
||||
throw new ServiceError("Invalid support message", 400);
|
||||
if (!existing.data) {
|
||||
await limit("support:message:" + hash, 30, 60);
|
||||
required(
|
||||
await db
|
||||
.from("bug_report_messages")
|
||||
.upsert(
|
||||
{
|
||||
client_id: body.clientId,
|
||||
report_id: id,
|
||||
kind: "user",
|
||||
body: body.text,
|
||||
author_name: profile?.display_name ?? record.display_name,
|
||||
},
|
||||
{ onConflict: "report_id,client_id", ignoreDuplicates: true },
|
||||
)
|
||||
.select("id"),
|
||||
);
|
||||
const inserted = required(
|
||||
await db
|
||||
.from("bug_report_messages")
|
||||
.select("report_id,kind")
|
||||
.eq("report_id", id)
|
||||
.eq("client_id", body.clientId)
|
||||
.single(),
|
||||
);
|
||||
if (inserted.report_id !== id || inserted.kind !== "user")
|
||||
throw new ServiceError("Invalid support message", 400);
|
||||
}
|
||||
syncBugAfterResponse(id);
|
||||
return respond(await view(record));
|
||||
}
|
||||
if (path[2] === "claim") {
|
||||
if (!account) throw new ServiceError("Sign in to link this report", 401);
|
||||
required(
|
||||
await db.rpc("claim_bug_report", {
|
||||
p_report: id,
|
||||
p_hash: hash,
|
||||
p_owner: account.id,
|
||||
}),
|
||||
);
|
||||
return respond(await view(await owned(id)));
|
||||
}
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
@@ -26,6 +26,8 @@ export type MapListing = {
|
||||
image?: string;
|
||||
mapImage?: string;
|
||||
credit?: string;
|
||||
creditGlow?: boolean;
|
||||
creditAppearance?: import("../shared/profile-appearance").ProfileAppearance;
|
||||
};
|
||||
export async function mediaUrl(path: string | null, expires = 3600) {
|
||||
return (await mediaUrls([path], expires))[0];
|
||||
@@ -61,7 +63,9 @@ export async function catalog() {
|
||||
? required(
|
||||
await client
|
||||
.from("profiles")
|
||||
.select("id,display_name")
|
||||
.select(
|
||||
"id,display_name,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
|
||||
)
|
||||
.in("id", [...new Set(maps.map((m) => m.owner_id))]),
|
||||
)
|
||||
: [];
|
||||
@@ -71,6 +75,8 @@ export async function catalog() {
|
||||
return maps.map((m, index) => ({
|
||||
...m,
|
||||
credit: owners.find((p) => p.id === m.owner_id)?.display_name ?? "Explorer",
|
||||
creditGlow: owners.find((p) => p.id === m.owner_id)?.name_glow ?? false,
|
||||
creditAppearance: owners.find((p) => p.id === m.owner_id),
|
||||
image: signed[index * 2] ?? undefined,
|
||||
mapImage: signed[index * 2 + 1] ?? undefined,
|
||||
server: servers.find((s) => s.id === m.server_id),
|
||||
@@ -90,7 +96,9 @@ export async function getMap(id: string) {
|
||||
const [owner, signed] = await Promise.all([
|
||||
admin()
|
||||
.from("profiles")
|
||||
.select("display_name")
|
||||
.select(
|
||||
"display_name,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
|
||||
)
|
||||
.eq("id", map.owner_id)
|
||||
.maybeSingle(),
|
||||
mediaUrls([map.cover_path, map.map_path]),
|
||||
@@ -98,6 +106,8 @@ export async function getMap(id: string) {
|
||||
return {
|
||||
...map,
|
||||
credit: owner.data?.display_name ?? "Explorer",
|
||||
creditGlow: owner.data?.name_glow ?? false,
|
||||
creditAppearance: owner.data ?? undefined,
|
||||
image: signed[0],
|
||||
mapImage: signed[1],
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function leaderboard({
|
||||
limit?: number;
|
||||
} = {}): Promise<LeaderboardData | null> {
|
||||
try {
|
||||
return required(
|
||||
const data = required(
|
||||
await admin().rpc("player_leaderboard", {
|
||||
p_difficulty: difficulty,
|
||||
p_search: search,
|
||||
@@ -25,6 +25,30 @@ export async function leaderboard({
|
||||
p_limit: limit,
|
||||
}),
|
||||
) as unknown as LeaderboardData;
|
||||
const ids = [
|
||||
...new Set(
|
||||
[...data.entries, ...data.leaders].map((entry) => entry.player_id),
|
||||
),
|
||||
];
|
||||
const profiles = ids.length
|
||||
? required(
|
||||
await admin()
|
||||
.from("profiles")
|
||||
.select(
|
||||
"id,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
|
||||
)
|
||||
.in("id", ids),
|
||||
)
|
||||
: [];
|
||||
const addGlow = (entry: LeaderboardData["entries"][number]) => ({
|
||||
...entry,
|
||||
...profiles.find((profile) => profile.id === entry.player_id),
|
||||
});
|
||||
return {
|
||||
...data,
|
||||
entries: data.entries.map(addGlow),
|
||||
leaders: data.leaders.map(addGlow),
|
||||
};
|
||||
} catch (error) {
|
||||
// A leaderboard outage must not take the map catalog or homepage offline.
|
||||
console.error(
|
||||
|
||||
168
lib/linear-support-client.ts
Normal file
168
lib/linear-support-client.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
escapeSupportMarkdown,
|
||||
type BugReportRecord,
|
||||
} from "../shared/bug-reports";
|
||||
|
||||
type Issue = { id: string; identifier: string; team: { id: string } };
|
||||
export class LinearSupportClient {
|
||||
constructor(
|
||||
private key: string,
|
||||
private team: string,
|
||||
private project?: string,
|
||||
private request: typeof fetch = fetch,
|
||||
) {}
|
||||
async graphql<T>(
|
||||
query: string,
|
||||
variables: object,
|
||||
missing = false,
|
||||
): Promise<T | null> {
|
||||
const response = await this.request("https://api.linear.app/graphql", {
|
||||
method: "POST",
|
||||
headers: { Authorization: this.key, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (
|
||||
missing &&
|
||||
response.ok &&
|
||||
result.errors?.length &&
|
||||
result.errors?.every(
|
||||
(error: { message?: string; extensions?: { code?: string } }) =>
|
||||
error.extensions?.code === "ENTITY_NOT_FOUND" ||
|
||||
/^Entity not found/i.test(error.message ?? ""),
|
||||
)
|
||||
)
|
||||
return null;
|
||||
if (!response.ok || result.errors?.length || !result.data)
|
||||
throw new Error("Support delivery is temporarily unavailable");
|
||||
return result.data;
|
||||
}
|
||||
async issue(id: string): Promise<Issue | null> {
|
||||
const data = await this.graphql<{ issue: Issue }>(
|
||||
"query SupportIssue($id: String!) { issue(id: $id) { id identifier team { id } } }",
|
||||
{ id },
|
||||
true,
|
||||
);
|
||||
const issue = data?.issue ?? null;
|
||||
if (issue && issue.team.id !== this.team)
|
||||
throw new Error("Support issue team mismatch");
|
||||
return issue;
|
||||
}
|
||||
async createIssue(report: BugReportRecord): Promise<Issue> {
|
||||
const existing = await this.issue(report.id);
|
||||
if (existing) return existing;
|
||||
const description = [
|
||||
"#user-report-message",
|
||||
`Reporter: ${escapeSupportMarkdown(report.display_name)}`,
|
||||
`Page: ${report.page_path}`,
|
||||
"",
|
||||
escapeSupportMarkdown(report.description),
|
||||
"",
|
||||
report.linear_asset_url
|
||||
? ``
|
||||
: "",
|
||||
"",
|
||||
"Reply to the player with #player-report-send followed by your message.",
|
||||
"Grant or remove the username reward with #player-report-glow grant or #player-report-glow revoke.",
|
||||
`Report reference: ${report.id}`,
|
||||
].join("\n");
|
||||
try {
|
||||
const data = await this.graphql<{
|
||||
issueCreate: { success: boolean; issue: Issue };
|
||||
}>(
|
||||
"mutation SupportIssueCreate($input: IssueCreateInput!) { issueCreate(input:$input) { success issue { id identifier team { id } } } }",
|
||||
{
|
||||
input: {
|
||||
id: report.id,
|
||||
teamId: this.team,
|
||||
...(this.project ? { projectId: this.project } : {}),
|
||||
title: `[Player bug] ${report.title}`,
|
||||
description,
|
||||
priority: 3,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (
|
||||
!data?.issueCreate.success ||
|
||||
data.issueCreate.issue?.id !== report.id
|
||||
)
|
||||
throw new Error("Support issue creation failed");
|
||||
return data.issueCreate.issue;
|
||||
} catch (error) {
|
||||
// A timed-out creation may have succeeded. A stable UUID prevents a second issue.
|
||||
const recovered = await this.issue(report.id);
|
||||
if (recovered) return recovered;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async comment(id: string, reportId: string, name: string, text: string) {
|
||||
const lookup = () =>
|
||||
this.graphql<{ comment: { id: string; issue: { id: string } } }>(
|
||||
"query SupportComment($id:String!) { comment(id:$id) { id issue { id } } }",
|
||||
{ id },
|
||||
true,
|
||||
);
|
||||
const existing = (await lookup())?.comment;
|
||||
if (existing) {
|
||||
if (existing.issue.id !== reportId)
|
||||
throw new Error("Support comment mismatch");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await this.graphql<{
|
||||
commentCreate: { success: boolean; comment: { id: string } };
|
||||
}>(
|
||||
"mutation SupportCommentCreate($input:CommentCreateInput!) { commentCreate(input:$input) { success comment { id } } }",
|
||||
{
|
||||
input: {
|
||||
id,
|
||||
issueId: reportId,
|
||||
body: `#user-report-message\n${escapeSupportMarkdown(name)}\n\n${escapeSupportMarkdown(text)}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!data?.commentCreate.success || data.commentCreate.comment?.id !== id)
|
||||
throw new Error("Support comment creation failed");
|
||||
} catch (error) {
|
||||
const recovered = (await lookup())?.comment;
|
||||
if (!recovered || recovered.issue.id !== reportId) throw error;
|
||||
}
|
||||
}
|
||||
async upload(bytes: Uint8Array, filename: string) {
|
||||
const data = await this.graphql<{
|
||||
fileUpload: {
|
||||
success: boolean;
|
||||
uploadFile: {
|
||||
uploadUrl: string;
|
||||
assetUrl: string;
|
||||
headers: { key: string; value: string }[];
|
||||
};
|
||||
};
|
||||
}>(
|
||||
'mutation SupportFile($size:Int!,$filename:String!){ fileUpload(contentType:"image/webp",filename:$filename,size:$size) { success uploadFile { uploadUrl assetUrl headers { key value } } } }',
|
||||
{ filename, size: bytes.length },
|
||||
);
|
||||
const file = data?.fileUpload.uploadFile;
|
||||
if (
|
||||
!data?.fileUpload.success ||
|
||||
!file ||
|
||||
new URL(file.uploadUrl).protocol !== "https:" ||
|
||||
new URL(file.assetUrl).protocol !== "https:"
|
||||
)
|
||||
throw new Error("Support screenshot upload failed");
|
||||
const headers = new Headers({
|
||||
"Content-Type": "image/webp",
|
||||
"Cache-Control": "private, max-age=31536000",
|
||||
});
|
||||
file.headers.forEach(({ key, value }) => headers.set(key, value));
|
||||
const result = await this.request(file.uploadUrl, {
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: Buffer.from(bytes),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!result.ok) throw new Error("Support screenshot upload failed");
|
||||
return file.assetUrl;
|
||||
}
|
||||
}
|
||||
60
lib/moderator-users-api.ts
Normal file
60
lib/moderator-users-api.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import "server-only";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { admin, required } from "./supabase";
|
||||
import { ServiceError, checkServiceError } from "./service-error";
|
||||
import { prefixUpdateSchema } from "@/shared/profile-appearance";
|
||||
|
||||
export async function moderatorUsersApi(
|
||||
request: NextRequest,
|
||||
path: string[],
|
||||
reviewer: string,
|
||||
jsonBody: (request: NextRequest) => Promise<unknown>,
|
||||
) {
|
||||
const db = admin();
|
||||
const columns =
|
||||
"id,display_name,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon" as const;
|
||||
const respond = (body: unknown) =>
|
||||
NextResponse.json(body, { headers: { "Cache-Control": "no-store" } });
|
||||
if (request.method === "GET" && path.length === 2) {
|
||||
const search = (request.nextUrl.searchParams.get("q") ?? "")
|
||||
.trim()
|
||||
.slice(0, 64);
|
||||
let query = db
|
||||
.from("profiles")
|
||||
.select(columns)
|
||||
.order("display_name")
|
||||
.order("id")
|
||||
.limit(50);
|
||||
if (search) {
|
||||
if (z.string().uuid().safeParse(search).success)
|
||||
query = query.eq("id", search);
|
||||
else
|
||||
query = query.ilike(
|
||||
"display_name",
|
||||
"%" + search.replace(/[\\%_]/g, "\\$&") + "%",
|
||||
);
|
||||
}
|
||||
return respond({ users: required(await query) });
|
||||
}
|
||||
if (request.method === "PATCH" && path.length === 3) {
|
||||
const id = z.string().uuid().parse(path[2]),
|
||||
body = prefixUpdateSchema.parse(await jsonBody(request));
|
||||
const updated = await db.rpc("set_user_prefix", {
|
||||
p_reviewer: reviewer,
|
||||
p_user: id,
|
||||
p_prefix: body.prefix,
|
||||
p_icon: body.icon,
|
||||
});
|
||||
checkServiceError(updated.error);
|
||||
const result = await db
|
||||
.from("profiles")
|
||||
.select(columns)
|
||||
.eq("id", id)
|
||||
.maybeSingle();
|
||||
if (result.error) required(result);
|
||||
if (!result.data) throw new ServiceError("Player not found", 404);
|
||||
return respond({ user: result.data });
|
||||
}
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { createHash, randomBytes } from "node:crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { admin, required, user } from "./supabase";
|
||||
import { accountProfile } from "./profile";
|
||||
import { multiplayerAppearance } from "./multiplayer-appearance";
|
||||
import { mediaUrls } from "./catalog";
|
||||
import { requestOrigin } from "./request-origin";
|
||||
import { ServiceError } from "./service-error";
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
roomGuessSchema,
|
||||
chatMessageSchema,
|
||||
type RoomView,
|
||||
type RoomChat,
|
||||
} from "../shared/multiplayer";
|
||||
import {
|
||||
datasetSchema,
|
||||
@@ -55,6 +58,7 @@ export async function multiplayerApi(
|
||||
const body = createRoomSchema.parse(await jsonBody(request));
|
||||
await limit("multiplayer:create:" + accessHash, 20, 3600);
|
||||
const account = await user();
|
||||
if (account) await accountProfile(account);
|
||||
const id = required(
|
||||
await client.rpc("create_map_room", {
|
||||
p_dataset: body.datasetId,
|
||||
@@ -65,6 +69,16 @@ export async function multiplayerApi(
|
||||
p_owner: account?.id ?? null,
|
||||
}),
|
||||
);
|
||||
if (account)
|
||||
required(
|
||||
await client
|
||||
.from("multiplayer_players")
|
||||
.update({ account_id: account.id })
|
||||
.eq("room_id", id)
|
||||
.eq("access_hash", accessHash)
|
||||
.is("account_id", null)
|
||||
.select("id"),
|
||||
);
|
||||
return respond({ id });
|
||||
}
|
||||
const id = z.string().uuid().parse(path[1]);
|
||||
@@ -81,16 +95,25 @@ export async function multiplayerApi(
|
||||
? chatMessageSchema.parse(await jsonBody(request))
|
||||
: null;
|
||||
if (body) await limit("multiplayer:chat:" + accessHash, 60, 60);
|
||||
return respond(
|
||||
required(
|
||||
await client.rpc("multiplayer_chat", {
|
||||
p_room: id,
|
||||
p_access_hash: accessHash,
|
||||
p_text: body?.text ?? null,
|
||||
p_client_id: body?.clientId ?? null,
|
||||
}),
|
||||
),
|
||||
const chat = required(
|
||||
await client.rpc("multiplayer_chat", {
|
||||
p_room: id,
|
||||
p_access_hash: accessHash,
|
||||
p_text: body?.text ?? null,
|
||||
p_client_id: body?.clientId ?? null,
|
||||
}),
|
||||
) as unknown as RoomChat;
|
||||
const appearances = await multiplayerAppearance(
|
||||
id,
|
||||
chat.messages.map((message) => message.playerId),
|
||||
);
|
||||
return respond({
|
||||
...chat,
|
||||
messages: chat.messages.map((message) => ({
|
||||
...message,
|
||||
...appearances.get(message.playerId),
|
||||
})),
|
||||
});
|
||||
}
|
||||
let payload: Record<string, string | number> = {};
|
||||
if (request.method === "POST" && path.length === 3) {
|
||||
@@ -121,6 +144,32 @@ export async function multiplayerApi(
|
||||
p_payload: payload,
|
||||
}),
|
||||
) as unknown as RoomView;
|
||||
if (action === "join" && room.you) {
|
||||
const account = await user();
|
||||
if (account) {
|
||||
await accountProfile(account);
|
||||
required(
|
||||
await client
|
||||
.from("multiplayer_players")
|
||||
.update({ account_id: account.id })
|
||||
.eq("id", room.you)
|
||||
.eq("room_id", id)
|
||||
.eq("access_hash", accessHash)
|
||||
.is("account_id", null)
|
||||
.select("id"),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (action !== "media") {
|
||||
const appearances = await multiplayerAppearance(
|
||||
id,
|
||||
room.players.map((player) => player.id),
|
||||
);
|
||||
room.players = room.players.map((player) => ({
|
||||
...player,
|
||||
...appearances.get(player.id),
|
||||
}));
|
||||
}
|
||||
if (action !== "media") return respond(room);
|
||||
if (!room.you) throw new ServiceError("Join this room to play", 403);
|
||||
if (["lobby", "closed"].includes(room.status))
|
||||
|
||||
42
lib/multiplayer-appearance.ts
Normal file
42
lib/multiplayer-appearance.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import "server-only";
|
||||
import { admin, required } from "./supabase";
|
||||
import type { ProfileAppearance } from "@/shared/profile-appearance";
|
||||
|
||||
export async function multiplayerAppearance(room: string, ids: string[]) {
|
||||
const db = admin(),
|
||||
result = new Map<string, { name: string; appearance: ProfileAppearance }>();
|
||||
if (!ids.length) return result;
|
||||
const players = required(
|
||||
await db
|
||||
.from("multiplayer_players")
|
||||
.select("id,account_id")
|
||||
.eq("room_id", room)
|
||||
.in("id", [...new Set(ids)]),
|
||||
);
|
||||
const accounts = [
|
||||
...new Set(
|
||||
players.flatMap((player) =>
|
||||
player.account_id ? [player.account_id] : [],
|
||||
),
|
||||
),
|
||||
];
|
||||
if (!accounts.length) return result;
|
||||
const profiles = required(
|
||||
await db
|
||||
.from("profiles")
|
||||
.select(
|
||||
"id,display_name,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
|
||||
)
|
||||
.in("id", accounts),
|
||||
);
|
||||
for (const player of players) {
|
||||
const profile = profiles.find(
|
||||
(profile) => profile.id === player.account_id,
|
||||
);
|
||||
if (profile) {
|
||||
const { id, display_name, ...appearance } = profile;
|
||||
result.set(player.id, { name: display_name, appearance });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -20,7 +20,9 @@ export async function accountProfile(account: User) {
|
||||
return required(
|
||||
await admin()
|
||||
.from("profiles")
|
||||
.select("id,display_name,role")
|
||||
.select(
|
||||
"id,display_name,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon",
|
||||
)
|
||||
.eq("id", account.id)
|
||||
.single(),
|
||||
);
|
||||
|
||||
28
lib/support-images.ts
Normal file
28
lib/support-images.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import sharp from "sharp";
|
||||
import { screenshotLimit } from "../shared/bug-reports";
|
||||
|
||||
export async function supportScreenshot(bytes: Uint8Array) {
|
||||
if (!bytes.length || bytes.length > screenshotLimit)
|
||||
throw new Error("Use a screenshot smaller than 5 MB");
|
||||
const image = sharp(bytes, {
|
||||
limitInputPixels: 24_000_000,
|
||||
failOn: "error",
|
||||
animated: false,
|
||||
});
|
||||
const meta = await image.metadata();
|
||||
if (
|
||||
!["png", "jpeg", "webp"].includes(meta.format ?? "") ||
|
||||
(meta.pages ?? 1) !== 1
|
||||
)
|
||||
throw new Error("Choose a PNG, JPG, or WebP screenshot");
|
||||
return image
|
||||
.rotate()
|
||||
.resize({
|
||||
width: 2400,
|
||||
height: 2400,
|
||||
fit: "inside",
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.webp({ quality: 85 })
|
||||
.toBuffer();
|
||||
}
|
||||
22
lib/support-signature.ts
Normal file
22
lib/support-signature.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
export function verifySupportWebhook(
|
||||
raw: string,
|
||||
signature: string | null,
|
||||
secret: string,
|
||||
now = Date.now(),
|
||||
) {
|
||||
if (!secret || !signature || !/^[a-f0-9]{64}$/i.test(signature)) return false;
|
||||
const expected = createHmac("sha256", secret).update(raw).digest();
|
||||
if (!timingSafeEqual(expected, Buffer.from(signature, "hex"))) return false;
|
||||
try {
|
||||
const timestamp = JSON.parse(raw).webhookTimestamp;
|
||||
return (
|
||||
typeof timestamp === "number" &&
|
||||
Number.isFinite(timestamp) &&
|
||||
Math.abs(now - timestamp) <= 60_000
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
123
lib/support-sync.ts
Normal file
123
lib/support-sync.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import "server-only";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { after } from "next/server";
|
||||
import { admin, required } from "./supabase";
|
||||
import { LinearSupportClient } from "./linear-support-client";
|
||||
|
||||
export function supportReady() {
|
||||
return !!(
|
||||
process.env.LINEAR_API_KEY &&
|
||||
process.env.LINEAR_TEAM_ID &&
|
||||
process.env.LINEAR_WEBHOOK_SECRET
|
||||
);
|
||||
}
|
||||
export async function syncBugReport(id: string) {
|
||||
if (!supportReady()) return;
|
||||
const db = admin(),
|
||||
worker = randomUUID();
|
||||
if (
|
||||
!required(
|
||||
await db.rpc("claim_bug_sync", { p_report: id, p_worker: worker }),
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const linear = new LinearSupportClient(
|
||||
process.env.LINEAR_API_KEY!,
|
||||
process.env.LINEAR_TEAM_ID!,
|
||||
process.env.LINEAR_PROJECT_ID,
|
||||
);
|
||||
const report = required(
|
||||
await db.from("bug_reports").select("*").eq("id", id).single(),
|
||||
);
|
||||
if (!report.issue_synced) {
|
||||
if (report.screenshot_path && !report.linear_asset_url) {
|
||||
const image = required(
|
||||
await db.storage
|
||||
.from("minegeo-support")
|
||||
.download(report.screenshot_path),
|
||||
);
|
||||
report.linear_asset_url = await linear.upload(
|
||||
new Uint8Array(await image.arrayBuffer()),
|
||||
`bug-${id}.webp`,
|
||||
);
|
||||
required(
|
||||
await db
|
||||
.from("bug_reports")
|
||||
.update({ linear_asset_url: report.linear_asset_url })
|
||||
.eq("id", id)
|
||||
.eq("lease_owner", worker)
|
||||
.select("id")
|
||||
.single(),
|
||||
);
|
||||
}
|
||||
const issue = await linear.createIssue(report);
|
||||
required(
|
||||
await db
|
||||
.from("bug_reports")
|
||||
.update({
|
||||
issue_synced: true,
|
||||
linear_identifier: issue.identifier,
|
||||
state: "open",
|
||||
})
|
||||
.eq("id", id)
|
||||
.eq("lease_owner", worker)
|
||||
.select("id")
|
||||
.single(),
|
||||
);
|
||||
}
|
||||
required(
|
||||
await db
|
||||
.from("bug_report_messages")
|
||||
.update({ delivered: true })
|
||||
.eq("id", id)
|
||||
.select("id"),
|
||||
);
|
||||
const messages = required(
|
||||
await db
|
||||
.from("bug_report_messages")
|
||||
.select("*")
|
||||
.eq("report_id", id)
|
||||
.eq("kind", "user")
|
||||
.eq("delivered", false)
|
||||
.neq("id", id)
|
||||
.order("created_at")
|
||||
.limit(50),
|
||||
);
|
||||
for (const message of messages) {
|
||||
await linear.comment(message.id, id, message.author_name, message.body);
|
||||
required(
|
||||
await db
|
||||
.from("bug_report_messages")
|
||||
.update({ delivered: true })
|
||||
.eq("id", message.id)
|
||||
.select("id"),
|
||||
);
|
||||
}
|
||||
required(
|
||||
await db
|
||||
.from("bug_reports")
|
||||
.update({ lease_owner: null, lease_until: null, retry_at: null })
|
||||
.eq("id", id)
|
||||
.eq("lease_owner", worker)
|
||||
.select("id"),
|
||||
);
|
||||
} catch {
|
||||
console.error("Bug report delivery paused; the saved report will retry.");
|
||||
required(
|
||||
await db
|
||||
.from("bug_reports")
|
||||
.update({
|
||||
lease_owner: null,
|
||||
lease_until: null,
|
||||
retry_at: new Date(Date.now() + 60_000).toISOString(),
|
||||
})
|
||||
.eq("id", id)
|
||||
.eq("lease_owner", worker)
|
||||
.select("id"),
|
||||
);
|
||||
}
|
||||
}
|
||||
export function syncBugAfterResponse(id: string) {
|
||||
after(() => syncBugReport(id));
|
||||
}
|
||||
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 { glowColorSchema } from "./profile-appearance";
|
||||
|
||||
export const displayNameSchema = z
|
||||
.string()
|
||||
@@ -38,8 +39,15 @@ export const authActionSchema = z.discriminatedUnion("action", [
|
||||
z.object({ action: z.literal("recover"), email }).strict(),
|
||||
]);
|
||||
export const profileUpdateSchema = z
|
||||
.object({ displayName: displayNameSchema })
|
||||
.strict();
|
||||
.object({
|
||||
displayName: displayNameSchema.optional(),
|
||||
glowColor: glowColorSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(value) => value.displayName !== undefined || value.glowColor !== undefined,
|
||||
"Choose a profile change",
|
||||
);
|
||||
export const initialPasswordSchema = z
|
||||
.object({ password: newPasswordSchema })
|
||||
.strict();
|
||||
@@ -61,10 +69,12 @@ export function authDestination(value: string | null | undefined): string {
|
||||
value === "/settings" ||
|
||||
value === "/dashboard" ||
|
||||
value === "/servers" ||
|
||||
value === "/support" ||
|
||||
value === "/auth/reset-password"
|
||||
)
|
||||
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;
|
||||
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: {
|
||||
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: {
|
||||
Row: {
|
||||
id: number;
|
||||
@@ -115,6 +139,7 @@ export type Database = {
|
||||
};
|
||||
multiplayer_players: {
|
||||
Row: {
|
||||
account_id: string | null;
|
||||
id: string;
|
||||
room_id: string;
|
||||
access_hash: string;
|
||||
@@ -124,6 +149,7 @@ export type Database = {
|
||||
joined_at: string;
|
||||
};
|
||||
Insert: {
|
||||
account_id?: string | null;
|
||||
id?: string;
|
||||
room_id: string;
|
||||
access_hash: string;
|
||||
@@ -132,7 +158,11 @@ export type Database = {
|
||||
has_left?: boolean;
|
||||
joined_at?: string;
|
||||
};
|
||||
Update: { ready_round?: number; has_left?: boolean };
|
||||
Update: {
|
||||
ready_round?: number;
|
||||
has_left?: boolean;
|
||||
account_id?: string | null;
|
||||
};
|
||||
Relationships: [];
|
||||
};
|
||||
multiplayer_guesses: {
|
||||
@@ -453,18 +483,33 @@ export type Database = {
|
||||
};
|
||||
profiles: {
|
||||
Row: {
|
||||
glow_color: string;
|
||||
bug_finder: boolean;
|
||||
custom_prefix: string | null;
|
||||
custom_prefix_icon: string;
|
||||
name_glow: boolean;
|
||||
created_at: string;
|
||||
display_name: string;
|
||||
id: string;
|
||||
role: string;
|
||||
};
|
||||
Insert: {
|
||||
glow_color?: string;
|
||||
bug_finder?: boolean;
|
||||
custom_prefix?: string | null;
|
||||
custom_prefix_icon?: string;
|
||||
name_glow?: boolean;
|
||||
created_at?: string;
|
||||
display_name?: string;
|
||||
id: string;
|
||||
role?: string;
|
||||
};
|
||||
Update: {
|
||||
glow_color?: string;
|
||||
bug_finder?: boolean;
|
||||
custom_prefix?: string | null;
|
||||
custom_prefix_icon?: string;
|
||||
name_glow?: boolean;
|
||||
created_at?: string;
|
||||
display_name?: string;
|
||||
id?: string;
|
||||
@@ -650,6 +695,33 @@ export type Database = {
|
||||
[_ in never]: never;
|
||||
};
|
||||
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: {
|
||||
Args: {
|
||||
p_owner: string;
|
||||
|
||||
@@ -5,14 +5,16 @@ export const leaderboardDifficulties = [
|
||||
"hard",
|
||||
] as const;
|
||||
export type LeaderboardDifficulty = (typeof leaderboardDifficulties)[number];
|
||||
export type LeaderboardEntry = {
|
||||
rank: number;
|
||||
player_id: string;
|
||||
display_name: string;
|
||||
best_score: number;
|
||||
average_score: number;
|
||||
games_played: number;
|
||||
};
|
||||
export type LeaderboardEntry =
|
||||
import("./profile-appearance").ProfileAppearance & {
|
||||
rank: number;
|
||||
player_id: string;
|
||||
display_name: string;
|
||||
name_glow?: boolean;
|
||||
best_score: number;
|
||||
average_score: number;
|
||||
games_played: number;
|
||||
};
|
||||
export type LeaderboardData = {
|
||||
entries: LeaderboardEntry[];
|
||||
leaders: LeaderboardEntry[];
|
||||
|
||||
@@ -714,5 +714,77 @@
|
||||
"Chat permission requires joining this room": "Чтобы пользоваться чатом, войдите в эту комнату",
|
||||
"Chat is unavailable in a closed or expired room": "Чат недоступен в закрытой комнате или комнате с истёкшим сроком",
|
||||
"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;
|
||||
playerId: string;
|
||||
name: string;
|
||||
appearance?: import("./profile-appearance").ProfileAppearance;
|
||||
text: string;
|
||||
createdAt: string;
|
||||
}[];
|
||||
@@ -62,6 +63,7 @@ export type RoomView = {
|
||||
players: {
|
||||
id: string;
|
||||
name: string;
|
||||
appearance?: import("./profile-appearance").ProfileAppearance;
|
||||
left: boolean;
|
||||
ready: 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