Harden gameplay timing and map loading
This commit is contained in:
@@ -4,7 +4,7 @@ MINEGEO_URL=http://localhost:3000
|
||||
MINEGEO_HOST=127.0.0.1
|
||||
# Optional comma-separated exact public origins for a reverse proxy or aliases.
|
||||
MINEGEO_ALLOWED_ORIGINS=
|
||||
# Testing only: true bypasses browser origin checks (including CSRF origin protection).
|
||||
# Development testing only; production rejects this bypass of CSRF protection.
|
||||
MINEGEO_ALLOW_ANY_ORIGIN=false
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
|
||||
|
||||
@@ -20,6 +20,13 @@ npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
Before public deployment, follow the [production launch guide](docs/production.md).
|
||||
`npm run check` runs unit tests, TypeScript checking and the production build.
|
||||
With the compiled site running, `npm run test:production -- http://localhost:3000`
|
||||
checks HTTP routes, privacy, signed images and five-round gameplay against the
|
||||
configured Supabase project. It creates and removes one temporary guest game;
|
||||
at least one map must be published.
|
||||
|
||||
`npm start` and `npm run dev` supervise both the website and processing worker. The worker is required to turn an upload into a previewable draft. `npm run worker` also loads `.env.local` when run separately. Setup downloads the pinned vanilla rendering assets and the Chromium runtime used by the existing terrain-rendering worker. Browser UI checks are separate from this worker.
|
||||
|
||||
Missing vanilla assets are also prepared automatically when a plugin export is rendered. For an existing failed or empty draft, stop the worker and use `npm run reprocess -- <dataset UUID>` before restarting. This retains the raw upload. See [empty-render recovery](docs/server-plugin.md#recover-an-empty-render).
|
||||
|
||||
@@ -6,6 +6,9 @@ async function route(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> },
|
||||
) {
|
||||
return handle(request, (await params).path);
|
||||
const response = await handle(request, (await params).path);
|
||||
// Includes private drafts, pairing credentials, token creation and errors.
|
||||
response.headers.set("Cache-Control", "private, no-store");
|
||||
return response;
|
||||
}
|
||||
export { route as GET, route as POST, route as PATCH, route as DELETE };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
export default function ErrorPage({ reset }: { reset: () => void }) {
|
||||
export default function ErrorPage({ retry }: { retry: () => void }) {
|
||||
return (
|
||||
<main className="section page-content">
|
||||
<h1>Lost connection.</h1>
|
||||
@@ -7,7 +7,7 @@ export default function ErrorPage({ reset }: { reset: () => void }) {
|
||||
We couldn’t load this part of the world. Check your connection and try
|
||||
again.
|
||||
</p>
|
||||
<button className="button primary" onClick={reset}>
|
||||
<button className="button primary" onClick={retry}>
|
||||
Try again
|
||||
</button>
|
||||
</main>
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
/* The same parchment surrounds the editor preview and interactive player map. */
|
||||
.detail-map,
|
||||
.map-surface {
|
||||
position: relative;
|
||||
border: 22px solid transparent;
|
||||
border-image: url("/images/map-parchment.svg") 12 fill / 22px / 0 stretch;
|
||||
background: #ddc79f;
|
||||
box-shadow: 4px 6px 0 #0005;
|
||||
}
|
||||
.map-load-error {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1001;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
background: #ddc79f;
|
||||
color: #493a28;
|
||||
}
|
||||
.detail-map {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export default async function Home() {
|
||||
alt="A Minecraft surface map framed in parchment"
|
||||
width={1024}
|
||||
height={1024}
|
||||
priority
|
||||
loading="eager"
|
||||
unoptimized
|
||||
/>
|
||||
<span className="square-pin pin-green" />
|
||||
|
||||
@@ -10,12 +10,8 @@ export default async function ServerPage({
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
let map;
|
||||
try {
|
||||
map = await getMap(id);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
const map = await getMap(id);
|
||||
if (!map) notFound();
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
@@ -34,7 +30,7 @@ export default async function ServerPage({
|
||||
<div>
|
||||
<div className="detail-map">
|
||||
<Image
|
||||
src={map.mapImage || map.image || ""}
|
||||
src={map.mapImage || map.image || "/images/example-map.webp"}
|
||||
alt={`${map.name} surface map`}
|
||||
width={1024}
|
||||
height={1024}
|
||||
|
||||
@@ -8,9 +8,9 @@ export function CatalogBrowser({ maps }: { maps: CardMap[] }) {
|
||||
const shown = maps.filter(
|
||||
(m) =>
|
||||
(source === "all" || m.source === source) &&
|
||||
(m.name + " " + m.description + " " + m.server?.name)
|
||||
(m.name + " " + m.description + " " + (m.server?.name ?? ""))
|
||||
.toLowerCase()
|
||||
.includes(query.toLowerCase()),
|
||||
.includes(query.trim().toLowerCase()),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
@@ -33,6 +33,7 @@ export function CatalogBrowser({ maps }: { maps: CardMap[] }) {
|
||||
<button
|
||||
key={id}
|
||||
className={source === id ? "active" : ""}
|
||||
aria-pressed={source === id}
|
||||
onClick={() => setSource(id)}
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -29,6 +29,7 @@ export function Dashboard() {
|
||||
]);
|
||||
setMaps(m.maps);
|
||||
setTokens(t.tokens);
|
||||
setError("");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
@@ -65,7 +66,11 @@ export function Dashboard() {
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
||||
);
|
||||
await client.auth.signOut();
|
||||
const { error: signOutError } = await client.auth.signOut();
|
||||
if (signOutError) {
|
||||
setError("Could not sign out. Please try again.");
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -9,10 +9,11 @@ import {
|
||||
MapPin,
|
||||
} from "lucide-react";
|
||||
import { Brand } from "./Brand";
|
||||
import { Panorama, preloadFaces } from "./Panorama";
|
||||
import { Panorama } from "./Panorama";
|
||||
import { GuessMap, type Pin } from "./GuessMap";
|
||||
import { api } from "./StartGame";
|
||||
import { difficulties, type Difficulty, type Bounds } from "@/shared/protocol";
|
||||
import { roundClock } from "@/shared/round-clock";
|
||||
export type RoundResult = {
|
||||
score: number;
|
||||
distance: number | null;
|
||||
@@ -44,15 +45,19 @@ export function Game({ id }: { id: string }) {
|
||||
[error, setError] = useState(""),
|
||||
[busy, setBusy] = useState(false),
|
||||
[result, setResult] = useState<RoundResult | null>(null),
|
||||
[remaining, setRemaining] = useState(0),
|
||||
[progress, setProgress] = useState(0);
|
||||
const offset = useRef(0),
|
||||
[remaining, setRemaining] = useState(0);
|
||||
const clock = useRef<ReturnType<typeof roundClock> | null>(null),
|
||||
loadVersion = useRef(0),
|
||||
guessing = useRef(false),
|
||||
activating = useRef(false),
|
||||
timingOut = useRef(false);
|
||||
const load = useCallback(async () => {
|
||||
const version = ++loadVersion.current;
|
||||
const sentAt = performance.now();
|
||||
try {
|
||||
const s = await api<SessionView>("sessions/" + id);
|
||||
offset.current = s.serverNow - Date.now();
|
||||
if (version !== loadVersion.current) return;
|
||||
clock.current = roundClock(s.serverNow, sentAt, performance.now());
|
||||
if (s.round >= 5) {
|
||||
router.replace("/results/" + id);
|
||||
return;
|
||||
@@ -60,31 +65,28 @@ export function Game({ id }: { id: string }) {
|
||||
setSession(s);
|
||||
setRemaining(
|
||||
s.rounds[s.round].deadline
|
||||
? Math.max(
|
||||
0,
|
||||
Math.ceil(
|
||||
(Date.parse(s.rounds[s.round].deadline!) - s.serverNow) / 1000,
|
||||
),
|
||||
? clock.current.remaining(
|
||||
s.rounds[s.round].deadline!,
|
||||
performance.now(),
|
||||
)
|
||||
: difficulties[s.difficulty].seconds,
|
||||
);
|
||||
s.rounds.forEach((r) =>
|
||||
preloadFaces(r.faces)
|
||||
.then(() => setProgress((p) => Math.min(100, p + 20)))
|
||||
.catch(() => {}),
|
||||
);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
if (version === loadVersion.current) setError((e as Error).message);
|
||||
}
|
||||
}, [id, router]);
|
||||
useEffect(() => {
|
||||
load();
|
||||
return () => {
|
||||
loadVersion.current++;
|
||||
};
|
||||
}, [load]);
|
||||
useEffect(() => {
|
||||
if (
|
||||
!session ||
|
||||
!loaded.panorama ||
|
||||
!loaded.map ||
|
||||
error ||
|
||||
result ||
|
||||
activating.current
|
||||
)
|
||||
@@ -92,28 +94,31 @@ export function Game({ id }: { id: string }) {
|
||||
const r = session.rounds[session.round];
|
||||
if (r.deadline) return;
|
||||
activating.current = true;
|
||||
const round = session.round;
|
||||
const sentAt = performance.now();
|
||||
api<{ deadline: string; serverNow: number }>(`sessions/${id}/activate`, {
|
||||
round: session.round,
|
||||
})
|
||||
.then((d) => {
|
||||
offset.current = d.serverNow - Date.now();
|
||||
clock.current = roundClock(d.serverNow, sentAt, performance.now());
|
||||
setSession((s) =>
|
||||
s
|
||||
s && s.round === round
|
||||
? {
|
||||
...s,
|
||||
rounds: s.rounds.map((r) =>
|
||||
r.number === s.round ? { ...r, deadline: d.deadline } : r,
|
||||
),
|
||||
}
|
||||
: null,
|
||||
: s,
|
||||
);
|
||||
})
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => (activating.current = false));
|
||||
}, [loaded, session, id, result]);
|
||||
}, [loaded, session, id, result, error]);
|
||||
const guess = useCallback(
|
||||
async (timeout = false) => {
|
||||
if (!session || busy || result || (!pin && !timeout)) return;
|
||||
if (!session || guessing.current || result || (!pin && !timeout)) return;
|
||||
guessing.current = true;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -122,26 +127,36 @@ export function Game({ id }: { id: string }) {
|
||||
...(!timeout && pin ? pin : {}),
|
||||
});
|
||||
setResult(r);
|
||||
setSession((s) =>
|
||||
s
|
||||
? {
|
||||
...s,
|
||||
rounds: s.rounds.map((round) =>
|
||||
round.number === session.round
|
||||
? { ...round, result: r }
|
||||
: round,
|
||||
),
|
||||
}
|
||||
: s,
|
||||
);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
timingOut.current = false;
|
||||
} finally {
|
||||
guessing.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[session, busy, result, pin, id],
|
||||
[session, result, pin, id],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!session || result) return;
|
||||
if (!session || result || error) return;
|
||||
const deadline = session.rounds[session.round].deadline;
|
||||
if (!deadline) return;
|
||||
const tick = () => {
|
||||
const left = Math.max(
|
||||
0,
|
||||
Math.ceil((Date.parse(deadline) - Date.now() - offset.current) / 1000),
|
||||
);
|
||||
const left = clock.current?.remaining(deadline, performance.now()) ?? 0;
|
||||
setRemaining(left);
|
||||
if (left === 0 && !timingOut.current) {
|
||||
if (left === 0 && !timingOut.current && !guessing.current) {
|
||||
timingOut.current = true;
|
||||
guess(true);
|
||||
}
|
||||
@@ -149,7 +164,7 @@ export function Game({ id }: { id: string }) {
|
||||
tick();
|
||||
const timer = setInterval(tick, 250);
|
||||
return () => clearInterval(timer);
|
||||
}, [session, result, guess]);
|
||||
}, [session, result, guess, error]);
|
||||
async function next() {
|
||||
if (!session) return;
|
||||
setSession(null);
|
||||
@@ -157,7 +172,7 @@ export function Game({ id }: { id: string }) {
|
||||
setPin(null);
|
||||
setLoaded({ panorama: false, map: false });
|
||||
timingOut.current = false;
|
||||
setProgress(0);
|
||||
setError("");
|
||||
await load();
|
||||
}
|
||||
const ready = useCallback(
|
||||
@@ -215,7 +230,7 @@ export function Game({ id }: { id: string }) {
|
||||
key={"map-" + session.round}
|
||||
bounds={session.bounds}
|
||||
image={session.map}
|
||||
pin={result?.guess || pin}
|
||||
pin={result ? result.guess : pin}
|
||||
actual={result?.actual}
|
||||
onPin={setPin}
|
||||
onReady={mapReady}
|
||||
@@ -252,7 +267,7 @@ export function Game({ id }: { id: string }) {
|
||||
<div className="game-loading">
|
||||
<div className="loading-cube" />
|
||||
<h2>Preparing your expedition.</h2>
|
||||
<p>Loading compressed panoramas · {progress}%</p>
|
||||
<p>Loading panorama and surface map…</p>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
@@ -267,7 +282,7 @@ export function Game({ id }: { id: string }) {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{result && (
|
||||
{result && !error && (
|
||||
<div className="round-result">
|
||||
<span className="eyebrow">
|
||||
ROUND {(session?.round || 0) + 1} COMPLETE
|
||||
|
||||
@@ -26,12 +26,21 @@ export function GuessMap({
|
||||
const root = useRef<HTMLDivElement>(null),
|
||||
markers = useRef<Leaflet.LayerGroup | null>(null),
|
||||
lib = useRef<typeof Leaflet | null>(null),
|
||||
[generation, setGeneration] = useState(0);
|
||||
[generation, setGeneration] = useState(0),
|
||||
[error, setError] = useState(""),
|
||||
[attempt, setAttempt] = useState(0);
|
||||
const callbacks = useRef({ onPin, onReady, onError, disabled });
|
||||
callbacks.current = { onPin, onReady, onError, disabled };
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let cleanup = () => {};
|
||||
setError("");
|
||||
const fail = (message: string) => {
|
||||
if (disposed) return;
|
||||
setError(message);
|
||||
callbacks.current.onError?.(message);
|
||||
};
|
||||
let loadTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
import("leaflet")
|
||||
.then((L) => {
|
||||
if (disposed || !root.current) return;
|
||||
@@ -48,13 +57,31 @@ export function GuessMap({
|
||||
maxBounds: b.pad(0.15),
|
||||
attributionControl: false,
|
||||
});
|
||||
// Install cleanup immediately, including when later initialization fails.
|
||||
let observer: ResizeObserver | undefined;
|
||||
cleanup = () => {
|
||||
observer?.disconnect();
|
||||
markers.current = null;
|
||||
lib.current = null;
|
||||
m.remove();
|
||||
};
|
||||
const overlay = L.imageOverlay(image, b);
|
||||
overlay.on("load", () => callbacks.current.onReady?.());
|
||||
overlay.on("error", () =>
|
||||
callbacks.current.onError?.(
|
||||
"The surface map could not load. Reconnect to retry.",
|
||||
),
|
||||
loadTimer = setTimeout(
|
||||
() =>
|
||||
fail("The surface map took too long to load. Reconnect to retry."),
|
||||
30000,
|
||||
);
|
||||
overlay.on("load", () => {
|
||||
clearTimeout(loadTimer);
|
||||
if (!disposed) {
|
||||
setError("");
|
||||
callbacks.current.onReady?.();
|
||||
}
|
||||
});
|
||||
overlay.on("error", () => {
|
||||
clearTimeout(loadTimer);
|
||||
fail("The surface map could not load. Reconnect to retry.");
|
||||
});
|
||||
overlay.addTo(m);
|
||||
m.setMaxZoom(Math.max(3, m.getBoundsZoom(b) + 3));
|
||||
m.fitBounds(b, { padding: [0, 0], animate: false });
|
||||
@@ -74,12 +101,33 @@ export function GuessMap({
|
||||
)
|
||||
callbacks.current.onPin?.({ x, z });
|
||||
});
|
||||
const keydown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key !== "Enter" ||
|
||||
event.target !== m.getContainer() ||
|
||||
callbacks.current.disabled
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
const center = m.getCenter();
|
||||
callbacks.current.onPin?.({
|
||||
x: Math.max(bounds.minX, Math.min(bounds.maxX, center.lng)),
|
||||
z: Math.max(bounds.minZ, Math.min(bounds.maxZ, -center.lat)),
|
||||
});
|
||||
};
|
||||
m.getContainer().addEventListener("keydown", keydown);
|
||||
const removeMap = cleanup;
|
||||
cleanup = () => {
|
||||
m.getContainer().removeEventListener("keydown", keydown);
|
||||
removeMap();
|
||||
};
|
||||
let fittedZoom = fitZoom;
|
||||
const observer = new ResizeObserver(() => {
|
||||
observer = new ResizeObserver(() => {
|
||||
if (!root.current?.clientWidth || !root.current.clientHeight) return;
|
||||
// Expanding the map should reveal the whole survey at its new size.
|
||||
// Preserve a player's chosen zoom and center while inspecting terrain.
|
||||
const wasFitted = Math.abs(m.getZoom() - fittedZoom) < 0.01;
|
||||
m.invalidateSize();
|
||||
m.invalidateSize({ pan: false });
|
||||
m.setMinZoom(-5);
|
||||
fittedZoom = m.getBoundsZoom(b);
|
||||
m.setMaxZoom(Math.max(3, fittedZoom + 3));
|
||||
@@ -88,20 +136,14 @@ export function GuessMap({
|
||||
m.setMinZoom(fittedZoom);
|
||||
});
|
||||
observer.observe(root.current);
|
||||
cleanup = () => {
|
||||
observer.disconnect();
|
||||
markers.current = null;
|
||||
m.remove();
|
||||
};
|
||||
})
|
||||
.catch(() =>
|
||||
callbacks.current.onError?.("The map viewer could not initialize."),
|
||||
);
|
||||
.catch(() => fail("The map viewer could not initialize."));
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearTimeout(loadTimer);
|
||||
cleanup();
|
||||
};
|
||||
}, [bounds.minX, bounds.maxX, bounds.minZ, bounds.maxZ, image]);
|
||||
}, [bounds.minX, bounds.maxX, bounds.minZ, bounds.maxZ, image, attempt]);
|
||||
useEffect(() => {
|
||||
const L = lib.current,
|
||||
group = markers.current;
|
||||
@@ -133,8 +175,23 @@ export function GuessMap({
|
||||
ref={root}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
role="application"
|
||||
aria-label="Minecraft map. North is up. Click to place a guess."
|
||||
aria-label={
|
||||
disabled
|
||||
? "Minecraft map. North is up."
|
||||
: "Minecraft map. North is up. Click to place a guess, or use arrow keys to pan and Enter to pin the center."
|
||||
}
|
||||
/>
|
||||
{error && (
|
||||
<div className="map-load-error" role="alert">
|
||||
<p>{error}</p>
|
||||
<button
|
||||
className="button"
|
||||
onClick={() => setAttempt((value) => value + 1)}
|
||||
>
|
||||
Retry map
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,8 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Plus, Minus, RotateCcw } from "lucide-react";
|
||||
import * as THREE from "three";
|
||||
const textureCache = new Map<string, Promise<THREE.Texture>>();
|
||||
export function preloadFaces(urls: string[]) {
|
||||
return Promise.all(
|
||||
urls.map((url) => {
|
||||
if (!textureCache.has(url))
|
||||
textureCache.set(
|
||||
url,
|
||||
new THREE.TextureLoader()
|
||||
.loadAsync(url)
|
||||
.then((t) => {
|
||||
t.colorSpace = THREE.SRGBColorSpace;
|
||||
return t;
|
||||
})
|
||||
.catch((e) => {
|
||||
textureCache.delete(url);
|
||||
throw e;
|
||||
}),
|
||||
);
|
||||
return textureCache.get(url)!;
|
||||
}),
|
||||
);
|
||||
}
|
||||
import { loadPanoramaTextures } from "@/lib/panorama-textures";
|
||||
// Explicit projection matches the six positive-FOV camera images emitted by
|
||||
// both exporters; it avoids CubeTexture's renderer-dependent orientation.
|
||||
const fragment = `uniform sampler2D px;uniform sampler2D nx;uniform sampler2D py;uniform sampler2D ny;uniform sampler2D pz;uniform sampler2D nz;varying vec3 vDirection;
|
||||
@@ -43,8 +22,7 @@ export function Panorama({
|
||||
onError: (message: string) => void;
|
||||
}) {
|
||||
const mount = useRef<HTMLDivElement>(null),
|
||||
view = useRef({ yaw: 0, pitch: 0, fov: 80 }),
|
||||
[fov, setFov] = useState(80);
|
||||
view = useRef({ yaw: 0, pitch: 0, fov: 80 });
|
||||
const callbacks = useRef({ onReady, onError });
|
||||
callbacks.current = { onReady, onError };
|
||||
useEffect(() => {
|
||||
@@ -53,7 +31,8 @@ export function Panorama({
|
||||
frame = 0,
|
||||
observer: ResizeObserver | undefined;
|
||||
let material: THREE.ShaderMaterial | undefined,
|
||||
geometry: THREE.SphereGeometry | undefined;
|
||||
geometry: THREE.SphereGeometry | undefined,
|
||||
ownedTextures: THREE.Texture[] = [];
|
||||
const element = mount.current!;
|
||||
view.current = { yaw: 0, pitch: 0, fov: 80 };
|
||||
const down = (e: PointerEvent) => {
|
||||
@@ -81,6 +60,13 @@ export function Panorama({
|
||||
Math.min(100, view.current.fov + e.deltaY * 0.04),
|
||||
);
|
||||
};
|
||||
const contextLost = (event: Event) => {
|
||||
event.preventDefault();
|
||||
cancelAnimationFrame(frame);
|
||||
callbacks.current.onError(
|
||||
"The panorama graphics context was lost. Reconnect to restore the view.",
|
||||
);
|
||||
};
|
||||
let drag: { x: number; y: number } | null = null;
|
||||
if (urls.length !== 6) {
|
||||
callbacks.current.onError(
|
||||
@@ -88,13 +74,18 @@ export function Panorama({
|
||||
);
|
||||
return;
|
||||
}
|
||||
preloadFaces(urls)
|
||||
loadPanoramaTextures(urls)
|
||||
.then((textures) => {
|
||||
if (disposed) return;
|
||||
if (disposed) {
|
||||
textures.forEach((texture) => texture.dispose());
|
||||
return;
|
||||
}
|
||||
ownedTextures = textures;
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
element.appendChild(renderer.domElement);
|
||||
renderer.domElement.addEventListener("webglcontextlost", contextLost);
|
||||
const scene = new THREE.Scene(),
|
||||
camera = new THREE.PerspectiveCamera(80, 1, 0.1, 10);
|
||||
geometry = new THREE.SphereGeometry(5, 32, 16);
|
||||
@@ -112,9 +103,11 @@ export function Panorama({
|
||||
});
|
||||
scene.add(new THREE.Mesh(geometry, material));
|
||||
const resize = () => {
|
||||
camera.aspect = element.clientWidth / element.clientHeight;
|
||||
const width = Math.max(1, element.clientWidth),
|
||||
height = Math.max(1, element.clientHeight);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer!.setSize(element.clientWidth, element.clientHeight);
|
||||
renderer!.setSize(width, height);
|
||||
};
|
||||
observer = new ResizeObserver(resize);
|
||||
observer.observe(element);
|
||||
@@ -157,13 +150,12 @@ export function Panorama({
|
||||
element.removeEventListener("wheel", wheel);
|
||||
geometry?.dispose();
|
||||
material?.dispose();
|
||||
ownedTextures.forEach((texture) => texture.dispose());
|
||||
renderer?.domElement.removeEventListener("webglcontextlost", contextLost);
|
||||
renderer?.dispose();
|
||||
renderer?.domElement.remove();
|
||||
};
|
||||
}, [urls, zoom]);
|
||||
useEffect(() => {
|
||||
view.current.fov = fov;
|
||||
}, [fov]);
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -177,13 +169,17 @@ export function Panorama({
|
||||
<>
|
||||
<button
|
||||
aria-label="Zoom in"
|
||||
onClick={() => setFov(Math.max(35, view.current.fov - 10))}
|
||||
onClick={() => {
|
||||
view.current.fov = Math.max(35, view.current.fov - 10);
|
||||
}}
|
||||
>
|
||||
<Plus size={17} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Zoom out"
|
||||
onClick={() => setFov(Math.min(100, view.current.fov + 10))}
|
||||
onClick={() => {
|
||||
view.current.fov = Math.min(100, view.current.fov + 10);
|
||||
}}
|
||||
>
|
||||
<Minus size={17} />
|
||||
</button>
|
||||
@@ -193,7 +189,6 @@ export function Panorama({
|
||||
aria-label="Reset view"
|
||||
onClick={() => {
|
||||
view.current = { yaw: 0, pitch: 0, fov: 80 };
|
||||
setFov(80);
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={17} />
|
||||
|
||||
@@ -11,12 +11,22 @@ export function Results({ id }: { id: string }) {
|
||||
[error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
setSession(null);
|
||||
setSelected(0);
|
||||
setError("");
|
||||
api<SessionView>("sessions/" + id)
|
||||
.then((s) => {
|
||||
if (disposed) return;
|
||||
if (s.round < 5) router.replace("/play/" + id);
|
||||
else setSession(s);
|
||||
})
|
||||
.catch((e) => setError(e.message));
|
||||
.catch((e) => {
|
||||
if (!disposed) setError(e.message);
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [id, router]);
|
||||
if (error) return <p role="alert">{error}</p>;
|
||||
if (!session) return <p className="muted">Unpacking your expedition…</p>;
|
||||
@@ -52,6 +62,7 @@ export function Results({ id }: { id: string }) {
|
||||
<button
|
||||
key={i}
|
||||
className={i === selected ? "active" : ""}
|
||||
aria-pressed={i === selected}
|
||||
onClick={() => setSelected(i)}
|
||||
>
|
||||
Round {i + 1}
|
||||
@@ -85,7 +96,7 @@ export function Results({ id }: { id: string }) {
|
||||
Another adventure →
|
||||
</Link>
|
||||
<Link className="button" href="/dashboard">
|
||||
My expeditions
|
||||
My maps
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,21 +3,8 @@ import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight, Timer, ZoomIn } from "lucide-react";
|
||||
import { difficulties, type Difficulty } from "@/shared/protocol";
|
||||
export async function api<T = Record<string, unknown>>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
method = body ? "POST" : "GET",
|
||||
): Promise<T> {
|
||||
const res = await fetch("/api/v1/" + path, {
|
||||
signal: AbortSignal.timeout(30000),
|
||||
method,
|
||||
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Request failed");
|
||||
return data;
|
||||
}
|
||||
import { api } from "@/lib/client-api";
|
||||
export { api } from "@/lib/client-api";
|
||||
export function StartGame({ datasetId }: { datasetId: string }) {
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>("normal"),
|
||||
[loading, setLoading] = useState(false),
|
||||
|
||||
@@ -36,6 +36,9 @@ MINEGEO_ALLOW_ANY_ORIGIN=true
|
||||
|
||||
This bypasses MineGeo's browser origin checks, including their CSRF protection.
|
||||
The default is `false`; only the exact value `true` enables the bypass.
|
||||
Use `npm run dev` for this testing mode. Production ignores the bypass, and
|
||||
`npm start` refuses to launch when it is enabled. Public production URLs must use
|
||||
HTTPS; loopback HTTP remains available for testing a production build locally.
|
||||
Authentication, permissions, and JSON request requirements still apply. This
|
||||
setting controls MineGeo's API checks; Supabase email sign-in still needs allowed
|
||||
redirect URLs, and `MINEGEO_URL` still controls capture-tool pairing links.
|
||||
|
||||
57
docs/production-audit.md
Normal file
57
docs/production-audit.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Map and production audit — October 5, 2026
|
||||
|
||||
Tracked in [Linear DUN-30](https://linear.app/dungeons-and-projects/issue/DUN-30/fix-map-and-gameplay-bugs-and-prepare-minegeo-for-production).
|
||||
|
||||
## Fixes
|
||||
|
||||
- Panorama viewers own and release their textures on unmount. Failed and stalled
|
||||
loads release completed and late-arriving faces. There is no unbounded global
|
||||
cache of every panorama and rotating signed URL.
|
||||
- Map-image failures and stalled loads show a retry control, including results.
|
||||
Map cleanup is installed before image initialization. Resizing keeps the map
|
||||
fitted or preserves the player's view. Keyboard users can pan with arrow keys
|
||||
and press Enter to place a pin at the center.
|
||||
- Countdown synchronization accounts for request latency and uses a monotonic
|
||||
clock. Submission has an immediate lock, failed timeout requests stop retrying
|
||||
behind an error dialog, current-round points update immediately, and timeout
|
||||
results do not retain an unsubmitted pin.
|
||||
- Session media signing uses a single batch for the map and 30 faces. Catalog
|
||||
signing deduplicates images. Existing revision/media authorization is retained.
|
||||
- Missing/invalid map IDs get a 404; backend failures reach the retryable error
|
||||
boundary. The error page now uses Next.js 16.3's `retry()` to fetch fresh data.
|
||||
Database transport/schema errors return 503 without exposing their internals.
|
||||
- All API responses prevent caching, guest HTTPS cookies respect the configured
|
||||
proxy origin, production disables the origin bypass, and baseline browser
|
||||
security headers are configured. Startup validates environment, heap and
|
||||
panorama size before launching either process.
|
||||
- Client requests explain offline, timeout and non-JSON gateway errors. Catalog
|
||||
searches trim whitespace, source/result selection exposes pressed state,
|
||||
successful dashboard refreshes clear stale errors, and sign-out failures stay
|
||||
visible. Results now label the dashboard link accurately as My maps.
|
||||
|
||||
## Validation
|
||||
|
||||
- 63 Node regression tests passed, including new client-error, texture-disposal,
|
||||
clock, startup-config, backend-error and production-origin tests.
|
||||
- TypeScript checking and the optimized Next.js 16.3.8 production build passed.
|
||||
- The HTTP/API/Storage smoke passed against both development and compiled
|
||||
production servers with the configured backend. It exercised pages/JARs,
|
||||
headers, privacy, signed map/cubemap downloads, five rounds, duplicate requests,
|
||||
out-of-bounds rejection and foreign-origin denial, then deleted its test games.
|
||||
- Codex's built-in browser verified the live homepage/catalog, trimmed search,
|
||||
hard-mode panorama and timeout, game reload/results and result-round selection.
|
||||
A separate local fixture verified failed map images, successful retry, keyboard
|
||||
guessing and rectangular-map resizing. Browser checks found no uncaught errors.
|
||||
- The production npm dependency audit reported zero known vulnerabilities.
|
||||
- The Supabase security advisor and a permissions query confirmed public workflow
|
||||
RPCs use invoker security and deny anon/authenticated execution. Informational
|
||||
no-policy notices concern deliberately server-only RLS tables.
|
||||
|
||||
## Launch requirements and limits
|
||||
|
||||
Leaked-password protection remains disabled in the configured Supabase project;
|
||||
it must be enabled in provider settings where the plan supports it. Production
|
||||
SMTP, emailed login/recovery delivery, public HTTPS/proxy configuration and
|
||||
restoration from backups were not exercised in this local audit. No deployment,
|
||||
new Minecraft client capture, or cross-platform worker benchmark was performed.
|
||||
Follow [the production guide](production.md) before public launch.
|
||||
55
docs/production.md
Normal file
55
docs/production.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Production launch
|
||||
|
||||
The website requires a Node.js host and a persistent rendering worker. Deploy
|
||||
this checkout with Node.js 22 or later, run `npm ci`, `npm run setup`, and
|
||||
`npm run check`, then start it with `npm start` under your host's process manager.
|
||||
The supervisor runs the website and worker together and stops the companion if
|
||||
either exits. A host that runs only Next.js must run `npm run worker` separately.
|
||||
|
||||
Configure these before starting:
|
||||
|
||||
- `MINEGEO_URL`: the public HTTPS origin, without a path, query or credentials.
|
||||
Loopback HTTP is supported for local production checks.
|
||||
- `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`, and the
|
||||
server-only `SUPABASE_SECRET_KEY`. The supervisor validates missing keys and
|
||||
rejects a server secret in the publishable-key field before starting children.
|
||||
- Keep `MINEGEO_ALLOW_ANY_ORIGIN=false`. Production ignores the bypass; the
|
||||
supervisor also rejects it at startup. Use `MINEGEO_ALLOWED_ORIGINS` for exact
|
||||
aliases and configure your reverse proxy to retain the public Host header.
|
||||
- Keep the default loopback binding behind a local HTTPS reverse proxy. Set
|
||||
`MINEGEO_HOST=0.0.0.0` only when your hosting/container network requires it.
|
||||
- Apply the existing `supabase/migrations` in filename order. This audit adds no
|
||||
database migration. Keep the media and raw buckets private.
|
||||
|
||||
Configure Supabase Auth's Site URL and allowed `/auth/callback` redirects for the
|
||||
public origin, email confirmation, working production SMTP, and a minimum
|
||||
password length of 12. Enable leaked-password protection when the plan supports
|
||||
it; the October 5 audit found it disabled. See the [account guide](account-settings.md)
|
||||
and [Supabase password security](https://supabase.com/docs/guides/auth/password-security).
|
||||
|
||||
The site sends no-store headers for all application API responses, including
|
||||
drafts, pairing credentials, tokens and errors. Guest cookies are HttpOnly,
|
||||
SameSite=Lax, and Secure for the configured HTTPS public host behind a proxy.
|
||||
The response headers prevent framing and MIME sniffing, limit referrers, and
|
||||
disable unused camera, microphone and location permissions. Configure HTTPS
|
||||
certificates and HSTS at the public reverse proxy.
|
||||
|
||||
Monitor `/api/v1/health`: HTTP 200 means its database query succeeded; 503 means
|
||||
configuration or the data service is unavailable. Also retain worker logs and
|
||||
monitor failed/queued jobs; website health does not assert worker liveness.
|
||||
Keep Supabase backups enabled and test restoration before storing irreplaceable
|
||||
community uploads.
|
||||
|
||||
After deployment, run:
|
||||
|
||||
```sh
|
||||
npm run test:production -- https://your-public-origin.example
|
||||
```
|
||||
|
||||
Run this from the configured checkout for the same Supabase project. The test
|
||||
checks pages/downloads, security headers, invalid map IDs, guest access denial,
|
||||
CSRF denial, malformed JSON, session isolation, real signed WebP downloads, and
|
||||
all five rounds with idempotent activation/submission and out-of-bounds rejection.
|
||||
It removes only its generated guest session. A published map is required.
|
||||
|
||||
See [the audit record](production-audit.md) for evidence and remaining limits.
|
||||
107
lib/api.ts
107
lib/api.ts
@@ -11,7 +11,8 @@ import {
|
||||
panoramaPath,
|
||||
viewpointSchema,
|
||||
} from "../shared/protocol";
|
||||
import { mediaUrl } from "./catalog";
|
||||
import { mediaUrl, mediaUrls } from "./catalog";
|
||||
import { ServiceError, checkServiceError } from "./service-error";
|
||||
import { requestOrigin, verifyOrigin } from "./request-origin";
|
||||
import { accountApi } from "./account-api";
|
||||
const hash = (value: string) =>
|
||||
@@ -107,7 +108,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
return await accountApi(request, path, { jsonBody, limit });
|
||||
const client = admin(),
|
||||
method = request.method;
|
||||
if (path[0] === "health") {
|
||||
if (path[0] === "health" && path.length === 1 && method === "GET") {
|
||||
const { error } = await client
|
||||
.from("catalog_servers")
|
||||
.select("id")
|
||||
@@ -117,7 +118,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
{ status: error ? 503 : 200 },
|
||||
);
|
||||
}
|
||||
if (method === "GET" && path[0] === "catalog") {
|
||||
if (method === "GET" && path[0] === "catalog" && path.length === 1) {
|
||||
const rows = required(
|
||||
await client.from("datasets").select("*").eq("state", "published"),
|
||||
);
|
||||
@@ -153,14 +154,16 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
response.cookies.set("mg_guest", secret, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: new URL(request.url).protocol === "https:",
|
||||
secure: requestOrigin(request, process.env.MINEGEO_URL).startsWith(
|
||||
"https:",
|
||||
),
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
const s = await session(request, path[1]);
|
||||
if (method === "GET") {
|
||||
if (method === "GET" && path.length === 2) {
|
||||
const revision = required(
|
||||
await client
|
||||
.from("revisions")
|
||||
@@ -176,6 +179,18 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
.eq("session_id", s.id)
|
||||
.order("round_number"),
|
||||
);
|
||||
const signed = await mediaUrls([
|
||||
`${revision.asset_root}/map.webp`,
|
||||
...rounds.flatMap((r) =>
|
||||
faces.map((f) =>
|
||||
panoramaPath(
|
||||
revision.asset_root,
|
||||
viewpointSchema.parse(r.viewpoint),
|
||||
`${f}.webp`,
|
||||
),
|
||||
),
|
||||
),
|
||||
]);
|
||||
return NextResponse.json(
|
||||
{
|
||||
id: s.id,
|
||||
@@ -183,31 +198,22 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
round: s.current_round,
|
||||
bounds: manifest.bounds,
|
||||
name: manifest.name,
|
||||
map: await mediaUrl(`${revision.asset_root}/map.webp`),
|
||||
rounds: await Promise.all(
|
||||
rounds.map(async (r) => ({
|
||||
number: r.round_number,
|
||||
deadline: r.deadline,
|
||||
result: r.result,
|
||||
faces: await Promise.all(
|
||||
faces.map((f) =>
|
||||
mediaUrl(
|
||||
panoramaPath(
|
||||
revision.asset_root,
|
||||
viewpointSchema.parse(r.viewpoint),
|
||||
`${f}.webp`,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
})),
|
||||
),
|
||||
map: signed[0],
|
||||
rounds: rounds.map((r, index) => ({
|
||||
number: r.round_number,
|
||||
deadline: r.deadline,
|
||||
result: r.result,
|
||||
faces: signed.slice(
|
||||
1 + index * faces.length,
|
||||
1 + (index + 1) * faces.length,
|
||||
),
|
||||
})),
|
||||
serverNow: Date.now(),
|
||||
},
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
if (method === "POST" && path[2] === "activate") {
|
||||
if (method === "POST" && path[2] === "activate" && path.length === 3) {
|
||||
const body = z
|
||||
.object({ round: z.number().int().min(0).max(4) })
|
||||
.parse(await jsonBody(request));
|
||||
@@ -220,7 +226,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
);
|
||||
return NextResponse.json({ deadline, serverNow: Date.now() });
|
||||
}
|
||||
if (method === "POST" && path[2] === "guess") {
|
||||
if (method === "POST" && path[2] === "guess" && path.length === 3) {
|
||||
const body = z
|
||||
.object({
|
||||
round: z.number().int().min(0).max(4),
|
||||
@@ -272,7 +278,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
p_dataset: d.id,
|
||||
p_owner: owner,
|
||||
});
|
||||
if (serverCheck.error) throw new Error(serverCheck.error.message);
|
||||
checkServiceError(serverCheck.error);
|
||||
const input = required(
|
||||
await client
|
||||
.from("dataset_inputs")
|
||||
@@ -371,7 +377,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
.select("state,progress,error,updated_at,lease_until")
|
||||
.eq("dataset_id", d.id)
|
||||
.maybeSingle();
|
||||
if (jobResult.error) throw new Error(jobResult.error.message);
|
||||
checkServiceError(jobResult.error);
|
||||
return NextResponse.json({
|
||||
...d,
|
||||
upload_jobs: jobResult.data,
|
||||
@@ -420,7 +426,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
p_owner: d.owner_id,
|
||||
p_address: body.contributionServerAddress ?? null,
|
||||
});
|
||||
if (error) throw new Error(error.message);
|
||||
checkServiceError(error);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
if (method === "PATCH") {
|
||||
@@ -438,7 +444,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
p_description: body.description,
|
||||
p_ids: body.selectedIds,
|
||||
});
|
||||
if (error) throw new Error(error.message);
|
||||
checkServiceError(error);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
if (method === "POST" && path[2] === "submit") {
|
||||
@@ -446,7 +452,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
p_dataset: d.id,
|
||||
p_owner: d.owner_id,
|
||||
});
|
||||
if (error) throw new Error(error.message);
|
||||
checkServiceError(error);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
if (method === "POST" && path[2] === "cancel") {
|
||||
@@ -455,7 +461,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
p_dataset: d.id,
|
||||
p_owner: d.owner_id,
|
||||
});
|
||||
if (error) throw new Error(error.message);
|
||||
checkServiceError(error);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
if (!["uploading", "processing"].includes(d.state))
|
||||
@@ -467,7 +473,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
.in("state", ["queued", "running"])
|
||||
.select("id")
|
||||
.maybeSingle();
|
||||
if (cancelled.error) throw new Error(cancelled.error.message);
|
||||
checkServiceError(cancelled.error);
|
||||
required(
|
||||
await client
|
||||
.from("datasets")
|
||||
@@ -571,7 +577,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
p_decision: body.decision,
|
||||
p_reason: body.reason ?? undefined,
|
||||
});
|
||||
if (error) throw new Error(error.message);
|
||||
checkServiceError(error);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
}
|
||||
@@ -657,7 +663,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
p_token: token,
|
||||
p_hash: hash(token),
|
||||
});
|
||||
if (error) throw new Error(error.message);
|
||||
checkServiceError(error);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
if (method === "POST" && path[1] === "poll") {
|
||||
@@ -781,7 +787,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
p_decision: b.decision,
|
||||
p_reason: b.reason ?? null,
|
||||
});
|
||||
if (error) throw new Error(error.message);
|
||||
checkServiceError(error);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
}
|
||||
@@ -824,16 +830,25 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
const message =
|
||||
error instanceof z.ZodError
|
||||
? "Invalid request: " + error.issues.map((i) => i.message).join("; ")
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "Request failed";
|
||||
const status = message.includes("Sign in")
|
||||
? 401
|
||||
: message.includes("permission") || message.includes("origin")
|
||||
? 403
|
||||
: message.includes("Too many")
|
||||
? 429
|
||||
: 400;
|
||||
: error instanceof SyntaxError
|
||||
? "Invalid JSON request"
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "Request failed";
|
||||
const status =
|
||||
path[0] === "health"
|
||||
? 503
|
||||
: error instanceof ServiceError
|
||||
? error.status
|
||||
: message.includes("Sign in")
|
||||
? 401
|
||||
: message.includes("permission") || message.includes("origin")
|
||||
? 403
|
||||
: message.includes("Too many")
|
||||
? 429
|
||||
: message.includes("not found")
|
||||
? 404
|
||||
: 400;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import "server-only";
|
||||
import { admin, required } from "./supabase";
|
||||
import { z } from "zod";
|
||||
import { ServiceError } from "./service-error";
|
||||
export type MapListing = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -23,12 +25,22 @@ export type MapListing = {
|
||||
credit?: string;
|
||||
};
|
||||
export async function mediaUrl(path: string | null, expires = 3600) {
|
||||
if (!path) return null;
|
||||
return (await mediaUrls([path], expires))[0];
|
||||
}
|
||||
export async function mediaUrls(paths: (string | null)[], expires = 3600) {
|
||||
const unique = [...new Set(paths.filter((path): path is string => !!path))];
|
||||
if (!unique.length) return paths.map(() => null);
|
||||
const { data, error } = await admin()
|
||||
.storage.from("minegeo-media")
|
||||
.createSignedUrl(path, expires);
|
||||
if (error) throw new Error(error.message);
|
||||
return data.signedUrl;
|
||||
.createSignedUrls(unique, expires);
|
||||
if (error || !data || data.some((item) => item.error || !item.signedUrl))
|
||||
throw new ServiceError(
|
||||
"Map images are temporarily unavailable. Please try again.",
|
||||
);
|
||||
const signed = new Map(
|
||||
data.map((item, index) => [unique[index], item.signedUrl]),
|
||||
);
|
||||
return paths.map((path) => (path ? (signed.get(path) ?? null) : null));
|
||||
}
|
||||
export async function catalog() {
|
||||
const client = admin();
|
||||
@@ -50,35 +62,40 @@ export async function catalog() {
|
||||
.in("id", [...new Set(maps.map((m) => m.owner_id))]),
|
||||
)
|
||||
: [];
|
||||
return Promise.all(
|
||||
maps.map(async (m) => ({
|
||||
...m,
|
||||
credit:
|
||||
owners.find((p) => p.id === m.owner_id)?.display_name ?? "Explorer",
|
||||
image: (await mediaUrl(m.map_path || m.cover_path)) ?? undefined,
|
||||
mapImage: (await mediaUrl(m.map_path)) ?? undefined,
|
||||
server: servers.find((s) => s.id === m.server_id),
|
||||
})),
|
||||
const signed = await mediaUrls(
|
||||
maps.flatMap((map) => [map.map_path || map.cover_path, map.map_path]),
|
||||
);
|
||||
return maps.map((m, index) => ({
|
||||
...m,
|
||||
credit: owners.find((p) => p.id === m.owner_id)?.display_name ?? "Explorer",
|
||||
image: signed[index * 2] ?? undefined,
|
||||
mapImage: signed[index * 2 + 1] ?? undefined,
|
||||
server: servers.find((s) => s.id === m.server_id),
|
||||
}));
|
||||
}
|
||||
export async function getMap(id: string) {
|
||||
const map = required(
|
||||
await admin()
|
||||
.from("datasets")
|
||||
.select("*")
|
||||
.eq("id", id)
|
||||
.eq("state", "published")
|
||||
.single(),
|
||||
) as MapListing;
|
||||
const owner = await admin()
|
||||
.from("profiles")
|
||||
.select("display_name")
|
||||
.eq("id", map.owner_id)
|
||||
if (!z.string().uuid().safeParse(id).success) return null;
|
||||
const result = await admin()
|
||||
.from("datasets")
|
||||
.select("*")
|
||||
.eq("id", id)
|
||||
.eq("state", "published")
|
||||
.maybeSingle();
|
||||
if (result.error) required(result);
|
||||
if (!result.data) return null;
|
||||
const map = result.data as unknown as MapListing;
|
||||
const [owner, signed] = await Promise.all([
|
||||
admin()
|
||||
.from("profiles")
|
||||
.select("display_name")
|
||||
.eq("id", map.owner_id)
|
||||
.maybeSingle(),
|
||||
mediaUrls([map.cover_path, map.map_path]),
|
||||
]);
|
||||
return {
|
||||
...map,
|
||||
credit: owner.data?.display_name ?? "Explorer",
|
||||
image: await mediaUrl(map.cover_path),
|
||||
mapImage: await mediaUrl(map.map_path),
|
||||
image: signed[0],
|
||||
mapImage: signed[1],
|
||||
};
|
||||
}
|
||||
|
||||
41
lib/client-api.ts
Normal file
41
lib/client-api.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export async function api<T = Record<string, unknown>>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
method = body === undefined ? "GET" : "POST",
|
||||
): Promise<T> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch("/api/v1/" + path, {
|
||||
signal: AbortSignal.timeout(30000),
|
||||
cache: "no-store",
|
||||
method,
|
||||
headers:
|
||||
body === undefined ? undefined : { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
["TimeoutError", "AbortError"].includes(error.name)
|
||||
)
|
||||
throw new Error("The request timed out. Reconnect and try again.");
|
||||
throw new Error(
|
||||
"Could not reach MineGeo. Check your connection and try again.",
|
||||
);
|
||||
}
|
||||
let data: T & { error?: string };
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
throw new Error(
|
||||
"MineGeo returned an unreadable response. Please try again.",
|
||||
);
|
||||
}
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
typeof data?.error === "string"
|
||||
? data.error
|
||||
: "Request failed. Please try again.",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
44
lib/panorama-textures.ts
Normal file
44
lib/panorama-textures.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
// Each viewer owns its textures. Browser HTTP caching handles repeat downloads;
|
||||
// keeping GPU resources in a global cache leaks every visited map and signed URL.
|
||||
export async function loadPanoramaTextures(
|
||||
urls: string[],
|
||||
loader: Pick<THREE.TextureLoader, "loadAsync"> = new THREE.TextureLoader(),
|
||||
timeoutMs = 30000,
|
||||
): Promise<THREE.Texture[]> {
|
||||
const textures: THREE.Texture[] = [];
|
||||
let failed = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
Promise.all(
|
||||
urls.map(async (url) => {
|
||||
const texture = await loader.loadAsync(url);
|
||||
if (failed) {
|
||||
texture.dispose();
|
||||
throw new Error("Panorama loading cancelled");
|
||||
}
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
textures.push(texture);
|
||||
return texture;
|
||||
}),
|
||||
),
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error("Panorama loading timed out. Reconnect to retry."),
|
||||
),
|
||||
timeoutMs,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
textures.forEach((texture) => texture.dispose());
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,9 @@ export function verifyOrigin(
|
||||
configuration: OriginConfiguration = {
|
||||
websiteUrl: process.env.MINEGEO_URL,
|
||||
additionalOrigins: process.env.MINEGEO_ALLOWED_ORIGINS,
|
||||
allowAnyOrigin: process.env.MINEGEO_ALLOW_ANY_ORIGIN === "true",
|
||||
allowAnyOrigin:
|
||||
process.env.NODE_ENV !== "production" &&
|
||||
process.env.MINEGEO_ALLOW_ANY_ORIGIN === "true",
|
||||
},
|
||||
) {
|
||||
if (request.method === "GET" || request.method === "HEAD") return;
|
||||
|
||||
16
lib/service-error.ts
Normal file
16
lib/service-error.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export class ServiceError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status = 503,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
console.error("Supabase request failed:", error.code, error.message);
|
||||
throw new ServiceError("MineGeo could not reach its data service. Please try again.");
|
||||
}
|
||||
@@ -4,11 +4,12 @@ import { createServerClient } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
import { cache } from "react";
|
||||
import type { Database } from "@/shared/database";
|
||||
import { ServiceError, checkServiceError } from "./service-error";
|
||||
let singleton: ReturnType<typeof createClient<Database>> | undefined;
|
||||
export function admin() {
|
||||
if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.SUPABASE_SECRET_KEY)
|
||||
throw new Error(
|
||||
"Supabase configuration missing. Run npm run setup and configure .env.local.",
|
||||
throw new ServiceError(
|
||||
"MineGeo is not configured yet. Please try again later.",
|
||||
);
|
||||
return (singleton ??= createClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
@@ -51,9 +52,9 @@ export const user = cache(async function user() {
|
||||
});
|
||||
export function required<T>(result: {
|
||||
data: T;
|
||||
error: { message: string } | null;
|
||||
error: { message: string; code?: string } | null;
|
||||
}): NonNullable<T> {
|
||||
if (result.error) throw new Error(result.error.message);
|
||||
checkServiceError(result.error);
|
||||
if (result.data === null || result.data === undefined)
|
||||
throw new Error("Record not found");
|
||||
return result.data as NonNullable<T>;
|
||||
|
||||
@@ -3,5 +3,25 @@ const config: NextConfig = {
|
||||
poweredByHeader: false,
|
||||
serverExternalPackages: ["pg"],
|
||||
turbopack: { root: process.cwd() },
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/:path*",
|
||||
headers: [
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
{ key: "Referrer-Policy", value: "same-origin" },
|
||||
{
|
||||
key: "Permissions-Policy",
|
||||
value: "camera=(), microphone=(), geolocation=()",
|
||||
},
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: "frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
"test": "tsx --test tests/*.test.ts",
|
||||
"test:renderer": "node scripts/test-renderer.mjs",
|
||||
"render:map": "node scripts/render-map.mjs",
|
||||
"test:e2e": "playwright test",
|
||||
"test:production": "node scripts/test-production.mjs",
|
||||
"check": "npm test && npm run typecheck -- --incremental false && npm run build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"renderer:proof": "node scripts/proof.mjs",
|
||||
"java:build": "node scripts/java-build.mjs"
|
||||
|
||||
63
scripts/runtime-config.mjs
Normal file
63
scripts/runtime-config.mjs
Normal file
@@ -0,0 +1,63 @@
|
||||
/** Validate before launching either companion process; never print key values. */
|
||||
export function validateRuntime(env, { production = false } = {}) {
|
||||
const missing = [
|
||||
"NEXT_PUBLIC_SUPABASE_URL",
|
||||
"NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY",
|
||||
"SUPABASE_SECRET_KEY",
|
||||
].filter((key) => !env[key]);
|
||||
if (missing.length)
|
||||
throw new Error(
|
||||
`Missing configuration: ${missing.join(", ")}. Configure .env.local or your host's environment.`,
|
||||
);
|
||||
const backend = new URL(env.NEXT_PUBLIC_SUPABASE_URL);
|
||||
if (
|
||||
!["http:", "https:"].includes(backend.protocol) ||
|
||||
backend.username ||
|
||||
backend.password
|
||||
)
|
||||
throw new Error(
|
||||
"NEXT_PUBLIC_SUPABASE_URL must be an HTTP(S) URL without credentials.",
|
||||
);
|
||||
if (
|
||||
env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY.startsWith("sb_secret_") ||
|
||||
env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY === env.SUPABASE_SECRET_KEY
|
||||
)
|
||||
throw new Error(
|
||||
"The public Supabase key must not contain the server secret.",
|
||||
);
|
||||
if (production) {
|
||||
if (env.MINEGEO_ALLOW_ANY_ORIGIN === "true")
|
||||
throw new Error("MINEGEO_ALLOW_ANY_ORIGIN must be false in production.");
|
||||
if (!env.MINEGEO_URL)
|
||||
throw new Error(
|
||||
"Set MINEGEO_URL to the website's public origin before starting production.",
|
||||
);
|
||||
const website = new URL(env.MINEGEO_URL);
|
||||
const local = ["localhost", "127.0.0.1", "[::1]"].includes(
|
||||
website.hostname,
|
||||
);
|
||||
if (
|
||||
(!local && website.protocol !== "https:") ||
|
||||
!["https:", "http:"].includes(website.protocol) ||
|
||||
website.username ||
|
||||
website.password ||
|
||||
website.pathname !== "/" ||
|
||||
website.search ||
|
||||
website.hash
|
||||
)
|
||||
throw new Error(
|
||||
"MINEGEO_URL must be an HTTPS origin (HTTP is allowed only on loopback).",
|
||||
);
|
||||
}
|
||||
const workerHeap = Number(env.MINEGEO_WORKER_HEAP_MB || 4096);
|
||||
if (!Number.isInteger(workerHeap) || workerHeap < 512 || workerHeap > 32768)
|
||||
throw new Error(
|
||||
"MINEGEO_WORKER_HEAP_MB must be an integer between 512 and 32768.",
|
||||
);
|
||||
const faceSize = Number(env.MINEGEO_FACE_SIZE || 1024);
|
||||
if (!Number.isInteger(faceSize) || faceSize < 256 || faceSize > 2048)
|
||||
throw new Error(
|
||||
"MINEGEO_FACE_SIZE must be an integer between 256 and 2048.",
|
||||
);
|
||||
return { workerHeap };
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { validateRuntime } from "./runtime-config.mjs";
|
||||
if (existsSync(".env.local")) process.loadEnvFile(".env.local");
|
||||
const dev = process.argv[2] === "dev";
|
||||
const { workerHeap } = validateRuntime(process.env, { production: !dev });
|
||||
const children = [];
|
||||
let stopping = false;
|
||||
function run(args) {
|
||||
const c = spawn(process.execPath, args, {
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
windowsHide: true,
|
||||
});
|
||||
children.push(c);
|
||||
c.on("exit", (code) => {
|
||||
@@ -34,10 +37,9 @@ run([
|
||||
"--hostname",
|
||||
process.env.MINEGEO_HOST || "127.0.0.1",
|
||||
]);
|
||||
const workerHeap = Number(process.env.MINEGEO_WORKER_HEAP_MB || 4096);
|
||||
if (!Number.isInteger(workerHeap) || workerHeap < 512 || workerHeap > 32768) {
|
||||
console.error("MINEGEO_WORKER_HEAP_MB must be an integer between 512 and 32768.");
|
||||
stop(1);
|
||||
} else {
|
||||
run([`--max-old-space-size=${workerHeap}`, "--import", "tsx", "worker/main.ts"]);
|
||||
}
|
||||
run([
|
||||
`--max-old-space-size=${workerHeap}`,
|
||||
"--import",
|
||||
"tsx",
|
||||
"worker/main.ts",
|
||||
]);
|
||||
|
||||
167
scripts/test-production.mjs
Normal file
167
scripts/test-production.mjs
Normal file
@@ -0,0 +1,167 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
if (existsSync(".env.local")) process.loadEnvFile(".env.local");
|
||||
const origin = new URL(
|
||||
process.argv[2] || process.env.MINEGEO_URL || "http://localhost:3000",
|
||||
).origin;
|
||||
const db = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
process.env.SUPABASE_SECRET_KEY,
|
||||
{ auth: { persistSession: false, autoRefreshToken: false } },
|
||||
);
|
||||
let sessionId, cookie;
|
||||
async function request(path, body, options = {}) {
|
||||
return fetch(origin + path, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
headers: {
|
||||
...(body === undefined
|
||||
? {}
|
||||
: { "content-type": "application/json", origin }),
|
||||
...(cookie ? { cookie } : {}),
|
||||
...options.headers,
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
}
|
||||
async function json(path, body) {
|
||||
const response = await request("/api/v1/" + path, body);
|
||||
assert.equal(
|
||||
response.status,
|
||||
200,
|
||||
`${path}: ${await response.clone().text()}`,
|
||||
);
|
||||
assert.match(response.headers.get("cache-control"), /no-store/);
|
||||
return response.json();
|
||||
}
|
||||
try {
|
||||
for (const path of [
|
||||
"/",
|
||||
"/servers",
|
||||
"/downloads",
|
||||
"/about",
|
||||
"/log-in",
|
||||
"/fonts/VT323-Regular.ttf",
|
||||
"/images/example-map.webp",
|
||||
"/tools/plugin/minegeo-plugin-1.21.11.jar",
|
||||
"/tools/mod/minegeo-fabric-1.21.11.jar",
|
||||
]) {
|
||||
const response = await request(path);
|
||||
assert.equal(response.status, 200, path);
|
||||
assert.equal(response.headers.get("x-content-type-options"), "nosniff");
|
||||
assert.equal(response.headers.get("x-frame-options"), "DENY");
|
||||
assert.ok((await response.arrayBuffer()).byteLength > 0, path);
|
||||
}
|
||||
assert.equal((await json("health")).ok, true);
|
||||
for (const path of [
|
||||
"/servers/not-a-uuid",
|
||||
"/servers/00000000-0000-4000-8000-000000000000",
|
||||
"/api/v1/catalog/extra",
|
||||
]) {
|
||||
assert.equal((await request(path)).status, 404, path);
|
||||
}
|
||||
for (const path of ["datasets", "tokens", "profile"]) {
|
||||
assert.equal(
|
||||
(await request("/api/v1/" + path)).status,
|
||||
401,
|
||||
`guest ${path}`,
|
||||
);
|
||||
}
|
||||
const forbidden = await request(
|
||||
"/api/v1/sessions",
|
||||
{},
|
||||
{ headers: { origin: "https://foreign.example" } },
|
||||
);
|
||||
assert.equal(forbidden.status, 403);
|
||||
const malformed = await fetch(origin + "/api/v1/sessions", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", origin },
|
||||
body: "{",
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
assert.equal(malformed.status, 400);
|
||||
assert.equal((await malformed.json()).error, "Invalid JSON request");
|
||||
const catalog = await json("catalog");
|
||||
assert.ok(
|
||||
catalog.maps.length,
|
||||
"Publish at least one map before verifying gameplay",
|
||||
);
|
||||
const map = catalog.maps[0];
|
||||
const created = await request("/api/v1/sessions", {
|
||||
datasetId: map.id,
|
||||
difficulty: "hard",
|
||||
});
|
||||
assert.equal(created.status, 200, await created.clone().text());
|
||||
sessionId = (await created.json()).id;
|
||||
cookie = created.headers.get("set-cookie").split(";")[0];
|
||||
const session = await json("sessions/" + sessionId);
|
||||
assert.equal(session.rounds.length, 5);
|
||||
assert.ok(
|
||||
session.rounds.every(
|
||||
(round) => round.faces.length === 6 && round.faces.every(Boolean),
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
(await fetch(origin + "/api/v1/sessions/" + sessionId)).status,
|
||||
404,
|
||||
"another guest cannot read the session",
|
||||
);
|
||||
// Exercise actual signed Storage reads, not just the URL response shape.
|
||||
for (const url of [session.map, ...session.rounds[0].faces]) {
|
||||
const image = await fetch(url, { signal: AbortSignal.timeout(30000) });
|
||||
assert.equal(image.status, 200);
|
||||
assert.match(image.headers.get("content-type"), /image\/webp/);
|
||||
assert.ok((await image.arrayBuffer()).byteLength > 0);
|
||||
}
|
||||
for (let round = 0; round < 5; round++) {
|
||||
const first = await json(`sessions/${sessionId}/activate`, { round });
|
||||
const duplicate = await json(`sessions/${sessionId}/activate`, { round });
|
||||
assert.equal(
|
||||
first.deadline,
|
||||
duplicate.deadline,
|
||||
"activation does not reset the timer",
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request(`/api/v1/sessions/${sessionId}/guess`, {
|
||||
round,
|
||||
x: map.bounds.maxX + 1,
|
||||
z: map.bounds.minZ,
|
||||
})
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
const guess = {
|
||||
round,
|
||||
x: (map.bounds.minX + map.bounds.maxX) / 2,
|
||||
z: (map.bounds.minZ + map.bounds.maxZ) / 2,
|
||||
};
|
||||
const result = await json(`sessions/${sessionId}/guess`, guess);
|
||||
assert.ok(result.score >= 0 && result.score <= 5000);
|
||||
assert.equal(result.timedOut, false);
|
||||
assert.deepEqual(
|
||||
await json(`sessions/${sessionId}/guess`, guess),
|
||||
result,
|
||||
"submission retries are idempotent",
|
||||
);
|
||||
}
|
||||
assert.equal((await json("sessions/" + sessionId)).round, 5);
|
||||
console.log(
|
||||
"Production HTTP/API/Storage smoke passed: pages, headers, privacy, origin checks, signed media and all five rounds.",
|
||||
);
|
||||
} finally {
|
||||
if (sessionId && cookie) {
|
||||
const secret = cookie.slice(cookie.indexOf("=") + 1);
|
||||
const { error } = await db
|
||||
.from("game_sessions")
|
||||
.delete()
|
||||
.eq("id", sessionId)
|
||||
.eq("access_hash", createHash("sha256").update(secret).digest("hex"))
|
||||
.is("owner_id", null);
|
||||
if (error)
|
||||
throw new Error("Smoke session cleanup failed: " + error.message);
|
||||
}
|
||||
}
|
||||
19
shared/round-clock.ts
Normal file
19
shared/round-clock.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// Synchronize at receipt using a bounded estimate of the return journey. The
|
||||
// countdown uses performance.now(), so changing the OS clock cannot extend it.
|
||||
export function roundClock(
|
||||
serverNow: number,
|
||||
sentAt: number,
|
||||
receivedAt: number,
|
||||
) {
|
||||
const serverAtReceipt = serverNow + Math.max(0, receivedAt - sentAt) / 2;
|
||||
return {
|
||||
remaining(deadline: string, now: number) {
|
||||
return Math.max(
|
||||
0,
|
||||
Math.ceil(
|
||||
(Date.parse(deadline) - serverAtReceipt - (now - receivedAt)) / 1000,
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
46
tests/client-api.test.ts
Normal file
46
tests/client-api.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { api } from "../lib/client-api";
|
||||
|
||||
test("API sends explicit falsy JSON bodies and prevents cached private responses", async (t) => {
|
||||
const fetch = t.mock.method(
|
||||
globalThis,
|
||||
"fetch",
|
||||
async (_url: unknown, options: RequestInit) => {
|
||||
assert.equal(options.method, "POST");
|
||||
assert.equal(options.body, "false");
|
||||
assert.equal(options.cache, "no-store");
|
||||
return Response.json({ ok: true });
|
||||
},
|
||||
);
|
||||
assert.deepEqual(await api("test", false), { ok: true });
|
||||
assert.equal(fetch.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
test("API explains HTML gateway failures, JSON errors, and empty error responses", async (t) => {
|
||||
const fetch = t.mock.method(
|
||||
globalThis,
|
||||
"fetch",
|
||||
async () => new Response("Bad gateway", { status: 502 }),
|
||||
);
|
||||
await assert.rejects(api("test"), /unreadable response/);
|
||||
fetch.mock.mockImplementation(async () =>
|
||||
Response.json({ error: "Map unavailable" }, { status: 404 }),
|
||||
);
|
||||
await assert.rejects(api("test"), /Map unavailable/);
|
||||
fetch.mock.mockImplementation(async () =>
|
||||
Response.json(null, { status: 503 }),
|
||||
);
|
||||
await assert.rejects(api("test"), /Request failed/);
|
||||
});
|
||||
|
||||
test("API reports timeouts and offline connections with a recovery action", async (t) => {
|
||||
const fetch = t.mock.method(globalThis, "fetch", async () => {
|
||||
throw new DOMException("timeout", "TimeoutError");
|
||||
});
|
||||
await assert.rejects(api("test"), /timed out.*Reconnect/);
|
||||
fetch.mock.mockImplementation(async () => {
|
||||
throw new TypeError("Failed to fetch");
|
||||
});
|
||||
await assert.rejects(api("test"), /Check your connection/);
|
||||
});
|
||||
59
tests/panorama-textures.test.ts
Normal file
59
tests/panorama-textures.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import * as THREE from "three";
|
||||
import { loadPanoramaTextures } from "../lib/panorama-textures";
|
||||
|
||||
function texture() {
|
||||
const value = new THREE.Texture();
|
||||
let disposals = 0;
|
||||
value.addEventListener("dispose", () => disposals++);
|
||||
return { value, disposals: () => disposals };
|
||||
}
|
||||
test("panorama textures retain face order and are owned separately by each viewer", async () => {
|
||||
const loader = { loadAsync: async () => new THREE.Texture() };
|
||||
const first = await loadPanoramaTextures(["px", "nx"], loader);
|
||||
const second = await loadPanoramaTextures(["px", "nx"], loader);
|
||||
assert.equal(first.length, 2);
|
||||
assert.equal(first[0].colorSpace, THREE.SRGBColorSpace);
|
||||
assert.notEqual(first[0], second[0]);
|
||||
[...first, ...second].forEach((value) => value.dispose());
|
||||
});
|
||||
|
||||
test("a failed panorama frees both loaded faces and faces that complete later", async () => {
|
||||
const early = texture(),
|
||||
late = texture();
|
||||
let finish!: (value: THREE.Texture) => void;
|
||||
const loader = {
|
||||
loadAsync: (url: string) =>
|
||||
url === "early"
|
||||
? Promise.resolve(early.value)
|
||||
: url === "late"
|
||||
? new Promise<THREE.Texture>((resolve) => {
|
||||
finish = resolve;
|
||||
})
|
||||
: Promise.reject(new Error("missing face")),
|
||||
};
|
||||
await assert.rejects(
|
||||
loadPanoramaTextures(["early", "late", "missing"], loader),
|
||||
/missing face/,
|
||||
);
|
||||
assert.equal(early.disposals(), 1);
|
||||
finish(late.value);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(late.disposals(), 1);
|
||||
});
|
||||
|
||||
test("stalled panoramas time out and release textures that arrive afterward", async () => {
|
||||
const late = texture();
|
||||
let finish!: (value: THREE.Texture) => void;
|
||||
const loader = {
|
||||
loadAsync: () =>
|
||||
new Promise<THREE.Texture>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
};
|
||||
await assert.rejects(loadPanoramaTextures(["late"], loader, 5), /timed out/);
|
||||
finish(late.value);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(late.disposals(), 1);
|
||||
});
|
||||
@@ -58,6 +58,26 @@ test("the testing switch retains JSON checks for requests without an origin", ()
|
||||
);
|
||||
});
|
||||
|
||||
test("production never enables the environment origin bypass", () => {
|
||||
const env = process.env as Record<string, string | undefined>;
|
||||
const previousNode = env.NODE_ENV,
|
||||
previousBypass = env.MINEGEO_ALLOW_ANY_ORIGIN;
|
||||
try {
|
||||
env.NODE_ENV = "production";
|
||||
env.MINEGEO_ALLOW_ANY_ORIGIN = "true";
|
||||
assert.throws(
|
||||
() =>
|
||||
verifyOrigin(makeRequest("localhost:3000", "https://foreign.example")),
|
||||
/Invalid request origin/,
|
||||
);
|
||||
} finally {
|
||||
if (previousNode === undefined) delete env.NODE_ENV;
|
||||
else env.NODE_ENV = previousNode;
|
||||
if (previousBypass === undefined) delete env.MINEGEO_ALLOW_ANY_ORIGIN;
|
||||
else env.MINEGEO_ALLOW_ANY_ORIGIN = previousBypass;
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts a Mac LAN origin when Next uses localhost internally", () => {
|
||||
const request = makeRequest("192.168.1.50:3000", "http://192.168.1.50:3000");
|
||||
assert.equal(requestOrigin(request), "http://192.168.1.50:3000");
|
||||
|
||||
20
tests/round-clock.test.ts
Normal file
20
tests/round-clock.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { roundClock } from "../shared/round-clock";
|
||||
|
||||
test("round timer accounts for return latency and stops at the server deadline", () => {
|
||||
const clock = roundClock(100000, 1000, 5000);
|
||||
const deadline = new Date(105000).toISOString();
|
||||
assert.equal(clock.remaining(deadline, 5000), 3);
|
||||
assert.equal(clock.remaining(deadline, 7001), 1);
|
||||
assert.equal(clock.remaining(deadline, 8000), 0);
|
||||
assert.equal(clock.remaining(deadline, 9000), 0);
|
||||
});
|
||||
|
||||
test("round timer relies on elapsed time even if the device wall clock changes", (t) => {
|
||||
const clock = roundClock(100000, 1000, 1000);
|
||||
t.mock.method(Date, "now", () => -10000000);
|
||||
assert.equal(clock.remaining(new Date(110000).toISOString(), 5000), 6);
|
||||
t.mock.method(Date, "now", () => 10000000);
|
||||
assert.equal(clock.remaining(new Date(110000).toISOString(), 11000), 0);
|
||||
});
|
||||
69
tests/runtime-config.test.ts
Normal file
69
tests/runtime-config.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { validateRuntime } from "../scripts/runtime-config.mjs";
|
||||
const env = {
|
||||
NEXT_PUBLIC_SUPABASE_URL: "https://project.supabase.co",
|
||||
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: "sb_publishable_example",
|
||||
SUPABASE_SECRET_KEY: "sb_secret_example",
|
||||
MINEGEO_URL: "https://minegeo.example",
|
||||
};
|
||||
test("production startup rejects CSRF bypass, secret exposure and missing config", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
validateRuntime(
|
||||
{ ...env, MINEGEO_ALLOW_ANY_ORIGIN: "true" },
|
||||
{ production: true },
|
||||
),
|
||||
/must be false/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateRuntime({
|
||||
...env,
|
||||
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: env.SUPABASE_SECRET_KEY,
|
||||
}),
|
||||
/server secret/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateRuntime({ ...env, SUPABASE_SECRET_KEY: "" }),
|
||||
/Missing configuration.*SUPABASE_SECRET_KEY/,
|
||||
);
|
||||
});
|
||||
test("production requires a secure public origin while allowing local production checks", () => {
|
||||
for (const url of [
|
||||
"http://minegeo.example",
|
||||
"https://user:pass@minegeo.example",
|
||||
"https://minegeo.example/path",
|
||||
"https://minegeo.example?x=1",
|
||||
])
|
||||
assert.throws(
|
||||
() => validateRuntime({ ...env, MINEGEO_URL: url }, { production: true }),
|
||||
/HTTPS origin/,
|
||||
);
|
||||
assert.equal(
|
||||
validateRuntime(
|
||||
{ ...env, MINEGEO_URL: "http://localhost:3000" },
|
||||
{ production: true },
|
||||
).workerHeap,
|
||||
4096,
|
||||
);
|
||||
assert.equal(validateRuntime(env, { production: true }).workerHeap, 4096);
|
||||
});
|
||||
test("startup validates worker heap and panorama size before spawning processes", () => {
|
||||
assert.throws(
|
||||
() => validateRuntime({ ...env, MINEGEO_WORKER_HEAP_MB: "NaN" }),
|
||||
/WORKER_HEAP/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateRuntime({ ...env, MINEGEO_FACE_SIZE: "8192" }),
|
||||
/FACE_SIZE/,
|
||||
);
|
||||
assert.equal(
|
||||
validateRuntime({
|
||||
...env,
|
||||
MINEGEO_WORKER_HEAP_MB: "2048",
|
||||
MINEGEO_FACE_SIZE: "256",
|
||||
}).workerHeap,
|
||||
2048,
|
||||
);
|
||||
});
|
||||
18
tests/service-error.test.ts
Normal file
18
tests/service-error.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { checkServiceError, ServiceError } from "../lib/service-error";
|
||||
|
||||
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.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.doesNotThrow(() => checkServiceError(null));
|
||||
});
|
||||
Reference in New Issue
Block a user