Harden gameplay timing and map loading
This commit is contained in:
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>;
|
||||
|
||||
Reference in New Issue
Block a user