Validate and deduplicate custom difficulty presets
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 6m4s

This commit is contained in:
2026-10-16 13:29:32 +05:00
parent 1a34eb71c2
commit 0bd05d6e0b
10 changed files with 427 additions and 41 deletions

View File

@@ -7,9 +7,11 @@ import { useRouter } from "next/navigation";
import { ArrowRight, Timer, ZoomIn, Users } from "lucide-react";
import {
difficulties,
customDifficultySchema,
type DifficultyPreset,
} from "@/shared/protocol";
import { api } from "@/lib/client-api";
import { mergeDifficultyPresets } from "@/shared/difficulty-presets";
export { api } from "@/lib/client-api";
export function StartGame({
datasetId,
@@ -32,13 +34,19 @@ export function StartGame({
[error, setError] = useState("");
const router = useRouter();
const starting = useRef(false);
const savingPreset = useRef(false);
useEffect(() => {
let disposed = false;
api<{ presets: DifficultyPreset[] }>("difficulty-presets")
.then(({ presets }) => {
if (!disposed) setCustomPresets(presets);
if (!disposed)
setCustomPresets((current) =>
mergeDifficultyPresets(current, presets),
);
})
.catch(() => {});
.catch((failure) => {
if (!disposed) setError(failure.message);
});
return () => {
disposed = true;
};
@@ -54,19 +62,25 @@ export function StartGame({
];
async function saveDifficulty(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (savingDifficulty) return;
if (savingPreset.current || starting.current) return;
const parsed = customDifficultySchema.safeParse({
name: difficultyName,
seconds: difficultySeconds,
zoom: difficultyZoom,
});
if (!parsed.success) {
setError(parsed.error.issues[0].message);
return;
}
savingPreset.current = true;
setSavingDifficulty(true);
setError("");
try {
const { preset } = await api<{ preset: DifficultyPreset }>(
"difficulty-presets",
{
name: difficultyName.trim(),
seconds: difficultySeconds,
zoom: difficultyZoom,
},
parsed.data,
);
setCustomPresets((current) => [...current, preset]);
setCustomPresets((current) => mergeDifficultyPresets(current, [preset]));
setDifficulty(preset.id);
setDifficultyName("");
setDifficultySeconds(90);
@@ -75,11 +89,12 @@ export function StartGame({
} catch (failure) {
setError((failure as Error).message);
} finally {
savingPreset.current = false;
setSavingDifficulty(false);
}
}
async function start() {
if (starting.current) return;
if (starting.current || savingPreset.current) return;
starting.current = true;
setLoading(true);
setError("");
@@ -168,17 +183,17 @@ export function StartGame({
<input
id="custom-difficulty-name"
value={difficultyName}
onChange={(event) => setDifficultyName(event.target.value)}
onChange={(event) =>
setDifficultyName([...event.target.value].slice(0, 40).join(""))
}
minLength={2}
maxLength={40}
maxLength={80}
required
autoComplete="off"
placeholder={t("Name this difficulty")}
disabled={savingDifficulty || loading}
/>
<label htmlFor="custom-difficulty-seconds">
{t("Round time")}
</label>
<label htmlFor="custom-difficulty-seconds">{t("Round time")}</label>
<div className="difficulty-builder-time">
<input
id="custom-difficulty-seconds"
@@ -253,7 +268,9 @@ export function StartGame({
<button
className="button primary full"
onClick={start}
disabled={loading || (mode === "multiplayer" && !hostName)}
disabled={
loading || savingDifficulty || (mode === "multiplayer" && !hostName)
}
>
{loading
? t("Preparing your world…")

View File

@@ -13,7 +13,11 @@ import {
viewpointSchema,
} from "../shared/protocol";
import { mediaUrl, mediaUrls } from "./catalog";
import { ServiceError, checkServiceError } from "./service-error";
import {
ServiceError,
checkServiceError,
checkDifficultyPresetError,
} from "./service-error";
import { requestOrigin, verifyOrigin } from "./request-origin";
import { browserAccount } from "./request-security";
import { jsonBody } from "./json-body";
@@ -165,12 +169,7 @@ export async function handle(request: NextRequest, path: string[]) {
.insert(body)
.select("id,name,seconds,zoom")
.single();
if (error?.code === "23505")
throw new ServiceError(
"A difficulty with that name already exists",
409,
);
checkServiceError(error);
checkDifficultyPresetError(error);
return NextResponse.json({ preset: data });
}
}
@@ -202,10 +201,7 @@ export async function handle(request: NextRequest, path: string[]) {
const body = z
.object({
datasetId: idSchema,
difficulty: z.union([
z.enum(["easy", "normal", "hard"]),
idSchema,
]),
difficulty: z.union([z.enum(["easy", "normal", "hard"]), idSchema]),
})
.parse(await jsonBody(request));
const secret = cookieSecret(request) || randomBytes(32).toString("hex");

View File

@@ -7,10 +7,28 @@ export class ServiceError extends Error {
}
}
export function checkServiceError(error: { message: string; code?: string } | null) {
export function checkDifficultyPresetError(
error: { message: string; code?: string } | null,
) {
if (error?.code === "23505")
throw new ServiceError("A difficulty with that name already exists", 409);
if (error?.code === "23514")
throw new ServiceError(
"Use a name with 2–40 characters and a round time of 15–600 seconds in 15-second steps",
400,
);
checkServiceError(error);
}
export function checkServiceError(
error: { message: string; code?: string } | null,
) {
if (!error) return;
if (error.code === "P0001") throw new Error(error.message);
if (error.code === "PGRST116") throw new ServiceError("Record not found", 404);
if (error.code === "PGRST116")
throw new ServiceError("Record not found", 404);
console.error("Supabase request failed:", error.code, error.message);
throw new ServiceError("MineGeo could not reach its data service. Please try again.");
throw new ServiceError(
"MineGeo could not reach its data service. Please try again.",
);
}

View File

@@ -22,6 +22,7 @@
"test:leaderboard": "node scripts/test-leaderboard-sql.mjs",
"test:multiplayer": "node scripts/test-multiplayer-sql.mjs",
"test:round-panoramas": "node scripts/test-round-panoramas-sql.mjs",
"test:difficulty-presets": "node scripts/test-difficulty-presets-sql.mjs",
"test:renderer": "node scripts/test-renderer.mjs",
"render:map": "node scripts/render-map.mjs",
"test:production": "node scripts/test-production.mjs",

View File

@@ -0,0 +1,181 @@
import assert from "node:assert/strict";
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,allowed_mime_types text[]);
create table storage.objects(id uuid primary key,bucket_id text,name text,owner_id text);
create function storage.foldername(text) returns text[] language sql as $$ select string_to_array($1, '/') $$;`);
for (const file of (await readdir("supabase/migrations"))
.filter((f) => f.endsWith(".sql"))
.sort()) {
if (file.endsWith("fix_difficulty_preset_names.sql"))
await assert.rejects(
db.query(
"insert into public.difficulty_presets(name,seconds,zoom) values('Normal',90,true)",
),
(error) => error.code === "23514",
);
await db.exec(await readFile(`supabase/migrations/${file}`, "utf8"));
}
const valid = [
"Normal",
"Speed 90",
"Hard",
"Тест",
"中文",
"😎😎",
"😎".repeat(40),
"a".repeat(40),
"a\\b",
];
for (const name of valid)
await db.query(
"insert into public.difficulty_presets(name,seconds,zoom) values($1,90,true)",
[name],
);
for (const name of [
"",
"a",
"😎",
"a".repeat(41),
"😎".repeat(41),
" leading",
"trailing ",
"x\nname",
"x\u0085name",
"x\u202ename",
"x\u2066name",
])
await assert.rejects(
db.query(
"insert into public.difficulty_presets(name,seconds,zoom) values($1,90,true)",
[name],
),
(error) => error.code === "23514",
);
for (const seconds of [0, 14, 16, 601])
await assert.rejects(
db.query(
"insert into public.difficulty_presets(name,seconds,zoom) values('Invalid clock',$1,true)",
[seconds],
),
(error) => error.code === "23514",
);
await assert.rejects(
db.query(
"insert into public.difficulty_presets(name,seconds,zoom) values('normal',90,true)",
),
(error) => error.code === "23505",
);
const preset = (
await db.query(
"insert into public.difficulty_presets(name,seconds,zoom) values('Playable preset 45',45,false) returning id",
)
).rows[0].id;
for (const [id, seconds] of [
["easy", 180],
["normal", 90],
["hard", 30],
])
assert.equal(
(
await db.query("select public.resolve_difficulty_preset($1) preset", [
id,
])
).rows[0].preset.seconds,
seconds,
);
await assert.rejects(
db.query(
"select public.resolve_difficulty_preset('00000000-0000-4000-8000-000000000000')",
),
/Choose a saved difficulty/,
);
assert.equal(
(
await db.query("select public.resolve_difficulty_preset($1) preset", [
preset.toUpperCase(),
])
).rows[0].preset.id,
preset,
);
const owner = (
await db.query(
"insert into auth.users values(gen_random_uuid()) returning id",
)
).rows[0].id;
const map = (
await db.query(
`insert into public.datasets(owner_id,name,source,bounds,state,panorama_count)
values($1,'Difficulty fixture','plugin','{"minX":0,"minZ":0,"maxX":64,"maxZ":64}','published',5) returning id`,
[owner],
)
).rows[0].id;
const manifest = {
viewpoints: Array.from({ length: 5 }, (_, i) => ({
id: `p${i}`,
x: i,
y: 65,
z: i,
})),
};
const revision = (
await db.query(
"insert into public.revisions(dataset_id,asset_root,manifest) values($1,'fixture',$2) returning id",
[map, JSON.stringify(manifest)],
)
).rows[0].id;
await db.query("update public.datasets set revision_id=$1 where id=$2", [
revision,
map,
]);
const solo = (
await db.query("select public.create_game($1,$2,$3,$4) id", [
map,
preset,
owner,
"a".repeat(64),
])
).rows[0].id;
const multi = (
await db.query("select public.create_map_room($1,$2,'Host',2,$3,$4) id", [
map,
preset,
"b".repeat(64),
owner,
])
).rows[0].id;
for (const [table, id] of [
["game_sessions", solo],
["multiplayer_rooms", multi],
])
assert.deepEqual(
(
await db.query(
`select difficulty_settings from public.${table} where id=$1`,
[id],
)
).rows[0].difficulty_settings,
{ id: preset, name: "Playable preset 45", seconds: 45, zoom: false },
);
assert.equal(
(
await db.query(
"select has_table_privilege('anon','public.difficulty_presets','INSERT') allowed",
)
).rows[0].allowed,
false,
);
console.log(
"Difficulty SQL regressions passed: original failure reproduced, normal/Unicode names, unsafe names, duplicates, time limits, solo/multiplayer rules and permissions.",
);
} finally {
await db.close();
}

View File

@@ -0,0 +1,13 @@
import type { DifficultyPreset } from "./protocol";
// A list fetched before a save must not remove the newly saved preset.
export function mergeDifficultyPresets(
current: DifficultyPreset[],
incoming: DifficultyPreset[],
) {
return [
...new Map(
[...current, ...incoming].map((preset) => [preset.id, preset]),
).values(),
].sort((a, b) => a.name.localeCompare(b.name));
}

View File

@@ -194,8 +194,10 @@ export const customDifficultySchema = z
name: z
.string()
.trim()
.min(2)
.max(40)
.refine(
(name) => [...name].length >= 2 && [...name].length <= 40,
"Difficulty names must contain 2–40 characters",
)
.refine(
(name) =>
!/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(

View File

@@ -0,0 +1,32 @@
-- The original U& expression doubled its Unicode escapes, so the regex
-- interpreted code-point text as a character range and rejected normal names.
alter table public.difficulty_presets
drop constraint difficulty_presets_name_check;
alter table public.difficulty_presets
add constraint difficulty_presets_name_check check (
name = btrim(name)
and length(name) between 2 and 40
and name !~ U&'[\0001-\001F\007F-\009F\202A-\202E\2066-\2069]'
);
-- A PL/pgSQL CASE without ELSE raises case_not_found for every saved UUID,
-- before reaching the custom-preset lookup below it.
create or replace function public.resolve_difficulty_preset(p_id text)
returns jsonb language plpgsql stable security invoker set search_path = '' as $$
declare preset jsonb;
begin
case p_id
when 'easy' then
return jsonb_build_object('id', 'easy', 'name', 'Easy', 'seconds', 180, 'zoom', true);
when 'normal' then
return jsonb_build_object('id', 'normal', 'name', 'Normal', 'seconds', 90, 'zoom', true);
when 'hard' then
return jsonb_build_object('id', 'hard', 'name', 'Hard', 'seconds', 30, 'zoom', false);
else null;
end case;
select jsonb_build_object('id', p.id::text, 'name', p.name, 'seconds', p.seconds, 'zoom', p.zoom)
into preset from public.difficulty_presets p where p.id::text = lower(p_id);
if not found then raise exception 'Choose a saved difficulty'; end if;
return preset;
end;
$$;

View File

@@ -0,0 +1,86 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { customDifficultySchema } from "../shared/protocol";
import { mergeDifficultyPresets } from "../shared/difficulty-presets";
const rules = { seconds: 90, zoom: true };
test("difficulty names accept normal text and count Unicode characters consistently with PostgreSQL", () => {
for (const name of [
"Normal",
"Speed 90",
"Тест",
"中文",
"😎😎",
"😎".repeat(40),
"a".repeat(40),
"a\\b",
])
assert.equal(
customDifficultySchema.safeParse({ name, ...rules }).success,
true,
name,
);
assert.equal(
customDifficultySchema.parse({ name: " My challenge ", ...rules }).name,
"My challenge",
);
for (const name of [
"",
"a",
"😎",
"a".repeat(41),
"😎".repeat(41),
"x\nname",
"x\u0085name",
"x\u202ename",
"x\u2066name",
])
assert.equal(
customDifficultySchema.safeParse({ name, ...rules }).success,
false,
);
});
test("difficulty rules enforce their supported range and strict request shape", () => {
for (const seconds of [15, 90, 600])
assert.equal(
customDifficultySchema.safeParse({ name: "Fixture", ...rules, seconds })
.success,
true,
);
for (const seconds of [0, 14, 16, 601, 15.5, NaN, Infinity, "90"])
assert.equal(
customDifficultySchema.safeParse({ name: "Fixture", ...rules, seconds })
.success,
false,
);
assert.equal(
customDifficultySchema.safeParse({
name: "Fixture",
...rules,
owner: "forged",
}).success,
false,
);
});
test("a delayed preset listing cannot erase a newly saved preset or duplicate it", async () => {
const old = { id: "old", name: "Old", ...rules };
const saved = { id: "saved", name: "New", ...rules };
let release!: (presets: (typeof old)[]) => void;
const response = new Promise<(typeof old)[]>((resolve) => {
release = resolve;
});
let presets = [old];
const loading = response.then((snapshot) => {
presets = mergeDifficultyPresets(presets, snapshot);
});
presets = mergeDifficultyPresets(presets, [saved]);
release([old]);
await loading;
assert.deepEqual(
presets.map((p) => p.id),
["saved", "old"],
);
assert.equal(mergeDifficultyPresets(presets, [saved]).length, 2);
});

View File

@@ -1,18 +1,58 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { checkServiceError, ServiceError } from "../lib/service-error";
import {
checkServiceError,
checkDifficultyPresetError,
ServiceError,
} from "../lib/service-error";
test("expected preset validation and duplicate-name errors return 400/409 without database log noise", (t) => {
const log = t.mock.method(console, "error", () => {});
for (const [code, status] of [
["23514", 400],
["23505", 409],
] as const)
assert.throws(
() =>
checkDifficultyPresetError({
code,
message: "private database constraint details",
}),
(error) => {
assert.ok(error instanceof ServiceError);
assert.equal(error.status, status);
assert.doesNotMatch(error.message, /private database|constraint/);
return true;
},
);
assert.equal(log.mock.callCount(), 0);
assert.doesNotThrow(() => checkDifficultyPresetError(null));
});
test("backend failures use 503 and keep database internals out of browser errors", (t) => {
t.mock.method(console, "error", () => {});
assert.throws(() => checkServiceError({ code: "42P01", message: 'relation "private_table" does not exist' }), (error) => {
assert.throws(
() =>
checkServiceError({
code: "42P01",
message: 'relation "private_table" does not exist',
}),
(error) => {
assert.ok(error instanceof ServiceError);
assert.equal(error.status, 503);
assert.doesNotMatch(error.message, /private_table|42P01/);
return true;
});
},
);
});
test("missing records and expected RPC validation keep actionable statuses and messages", () => {
assert.throws(() => checkServiceError({ code: "PGRST116", message: "no rows" }), (error) => error instanceof ServiceError && error.status === 404);
assert.throws(() => checkServiceError({ code: "P0001", message: "Guess outside map" }), /Guess outside map/);
assert.throws(
() => checkServiceError({ code: "PGRST116", message: "no rows" }),
(error) => error instanceof ServiceError && error.status === 404,
);
assert.throws(
() => checkServiceError({ code: "P0001", message: "Guess outside map" }),
/Guess outside map/,
);
assert.doesNotThrow(() => checkServiceError(null));
});