import "server-only"; import { createHash, randomBytes, randomUUID } from "node:crypto"; import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { admin, required, user } from "./supabase"; import { datasetSchema, faces, difficulties } from "../shared/protocol"; import { mediaUrl } from "./catalog"; import { requestOrigin, verifyOrigin } from "./request-origin"; import { selectDraftLocations } from "../shared/draft-selection"; const hash = (value: string) => createHash("sha256").update(value).digest("hex"); const idSchema = z.string().uuid(); const uuid = (value: string) => idSchema.parse(value); function cookieSecret(request: NextRequest) { return request.cookies.get("mg_guest")?.value ?? ""; } async function contributor(request: NextRequest) { const token = request.headers.get("authorization")?.replace(/^Bearer /, ""); let owner: string | undefined; if (token) { const row = required( await admin() .from("contributor_tokens") .select("owner_id") .eq("token_hash", hash(token)) .eq("revoked", false) .single(), ); owner = row.owner_id; } else owner = (await user())?.id; if (!owner) throw new Error("Sign in to continue"); const { error } = await admin() .from("profiles") .upsert({ id: owner }, { onConflict: "id", ignoreDuplicates: true }); if (error) throw new Error(error.message); return owner; } async function moderator(request: NextRequest) { const owner = await contributor(request); const profile = required( await admin().from("profiles").select("role").eq("id", owner).single(), ); if (profile.role !== "moderator") throw new Error("Moderator permission required"); return owner; } async function limit(key: string, count: number, seconds: number) { const allowed = required( await admin().rpc("check_rate", { p_key: key, p_limit: count, p_seconds: seconds, }), ); if (!allowed) throw new Error("Too many requests. Please wait and try again."); } async function ownedDataset(request: NextRequest, id: string) { const owner = await contributor(request); return required( await admin() .from("datasets") .select("*") .eq("id", uuid(id)) .eq("owner_id", owner) .single(), ); } async function session(request: NextRequest, id: string) { const record = required( await admin().from("game_sessions").select("*").eq("id", uuid(id)).single(), ); const secret = cookieSecret(request); if (secret && record.access_hash === hash(secret)) return record; const account = await user(); if (account && record.owner_id === account.id) return record; throw new Error("Session not found"); } async function jsonBody(request: NextRequest) { const reader = request.body?.getReader(); if (!reader) throw new Error("JSON request required"); const chunks: Uint8Array[] = []; let size = 0; while (true) { const { done, value } = await reader.read(); if (done) break; size += value.byteLength; if (size > 2 * 1024 * 1024) { await reader.cancel(); throw new Error("Request too large"); } chunks.push(value); } return JSON.parse(Buffer.concat(chunks).toString("utf8")); } export async function handle(request: NextRequest, path: string[]) { try { verifyOrigin(request); const client = admin(), method = request.method; if (path[0] === "health") { const { error } = await client .from("catalog_servers") .select("id") .limit(1); return NextResponse.json( { ok: !error, version: "0.1.0" }, { status: error ? 503 : 200 }, ); } if (method === "GET" && path[0] === "catalog") { const rows = required( await client.from("datasets").select("*").eq("state", "published"), ); return NextResponse.json({ maps: await Promise.all( rows.map(async (m) => ({ ...m, image: await mediaUrl(m.cover_path), })), ), }); } if (path[0] === "sessions") { if (method === "POST" && path.length === 1) { const body = z .object({ datasetId: idSchema, difficulty: z.enum(["easy", "normal", "hard"]), }) .parse(await jsonBody(request)); const secret = cookieSecret(request) || randomBytes(32).toString("hex"); await limit("session:" + hash(secret), 20, 3600); const account = await user(); const id = required( await client.rpc("create_game", { p_dataset: body.datasetId, p_difficulty: body.difficulty, p_owner: account?.id ?? null, p_access_hash: hash(secret), }), ); const response = NextResponse.json({ id }); response.cookies.set("mg_guest", secret, { httpOnly: true, sameSite: "lax", secure: new URL(request.url).protocol === "https:", path: "/", maxAge: 60 * 60 * 24 * 30, }); return response; } const s = await session(request, path[1]); if (method === "GET") { const revision = required( await client .from("revisions") .select("*") .eq("id", s.revision_id) .single(), ); const manifest = datasetSchema.parse(revision.manifest); const rounds = required( await client .from("game_rounds") .select("*") .eq("session_id", s.id) .order("round_number"), ); return NextResponse.json( { id: s.id, difficulty: s.difficulty, 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( `${revision.asset_root}/${z.object({ id: z.string() }).parse(r.viewpoint).id}/${f}.webp`, ), ), ), })), ), serverNow: Date.now(), }, { headers: { "Cache-Control": "no-store" } }, ); } if (method === "POST" && path[2] === "activate") { const body = z .object({ round: z.number().int().min(0).max(4) }) .parse(await jsonBody(request)); const deadline = required( await client.rpc("activate_round", { p_session: s.id, p_round: body.round, p_access_hash: s.access_hash, }), ); return NextResponse.json({ deadline, serverNow: Date.now() }); } if (method === "POST" && path[2] === "guess") { const body = z .object({ round: z.number().int().min(0).max(4), x: z.number().finite().optional(), z: z.number().finite().optional(), }) .parse(await jsonBody(request)); const result = required( await client.rpc("submit_guess", { p_session: s.id, p_round: body.round, p_access_hash: s.access_hash, p_x: body.x ?? null, p_z: body.z ?? null, }), ); return NextResponse.json(result); } } if (path[0] === "uploads") { const owner = await contributor(request); if (method === "POST" && path.length === 1) { await limit("upload:" + owner, 10, 3600); const manifest = datasetSchema.parse( (await jsonBody(request)).manifest, ); if ( manifest.assets.reduce((n, a) => n + a.bytes, 0) > Number(process.env.MINEGEO_MAX_UPLOAD_BYTES || 268435456) ) throw new Error("Dataset exceeds upload limit"); const { data: existing } = await client .from("catalog_servers") .select("id") .eq("name", manifest.serverName) .limit(1) .maybeSingle(); const serverId = existing?.id ?? required( await client .from("catalog_servers") .insert({ name: manifest.serverName, address: manifest.serverAddress ?? null, }) .select("id") .single(), ).id; const dataset = required( await client .from("datasets") .insert({ owner_id: owner, server_id: serverId, name: manifest.name, description: manifest.description, source: manifest.source, bounds: manifest.bounds, }) .select("id") .single(), ); required( await client .from("dataset_inputs") .insert({ dataset_id: dataset.id, manifest }) .select("dataset_id") .single(), ); return NextResponse.json({ datasetId: dataset.id }); } const body = z .object({ datasetId: idSchema }) .parse(await jsonBody(request)); const d = await ownedDataset(request, body.datasetId); if (method === "POST" && path[1] === "sign") { if (d.state !== "uploading") throw new Error("Dataset no longer accepts uploads"); const input = required( await client .from("dataset_inputs") .select("manifest") .eq("dataset_id", d.id) .single(), ); const endpoint = new URL( "/storage/v1/upload/resumable/sign", process.env.NEXT_PUBLIC_SUPABASE_URL, ); endpoint.hostname = endpoint.hostname.replace( ".supabase.co", ".storage.supabase.co", ); const assets = await Promise.all( datasetSchema .parse(input.manifest) .assets.map(async (a: { path: string }) => { const objectName = `${owner}/${d.id}/${a.path}`; const signed = required( await client.storage .from("minegeo-raw") .createSignedUploadUrl(objectName), ); return { path: a.path, objectName, token: signed.token, apiKey: process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY, endpoint: endpoint.toString(), }; }), ); return NextResponse.json({ assets }); } if (method === "POST" && path[1] === "finalize") { const state = required( await client.rpc("finalize_upload", { p_dataset: d.id, p_owner: owner, }), ); return NextResponse.json({ datasetId: d.id, state }); } } if (path[0] === "datasets") { if (method === "GET" && path.length === 1) { const owner = await contributor(request); const maps = required( await client .from("datasets") .select( "*, upload_jobs(state,progress,error,updated_at,lease_until)", ) .eq("owner_id", owner) .order("created_at", { ascending: false }), ); return NextResponse.json({ maps: await Promise.all( maps.map(async (m) => ({ ...m, image: await mediaUrl(m.cover_path), map: await mediaUrl(m.map_path), })), ), }); } const d = await ownedDataset(request, path[1]); if (method === "GET" && path[2] === "panoramas") { const input = required( await client .from("dataset_inputs") .select("manifest") .eq("dataset_id", d.id) .single(), ); const manifest = datasetSchema.parse(input.manifest); return panoramaResponse( d.id, manifest.availableViewpoints ?? manifest.viewpoints, path[3], ); } if (method === "GET") { const input = required( await client .from("dataset_inputs") .select("manifest") .eq("dataset_id", d.id) .single(), ); const manifest = datasetSchema.parse(input.manifest); const jobResult = await client .from("upload_jobs") .select("state,progress,error,updated_at,lease_until") .eq("dataset_id", d.id) .maybeSingle(); if (jobResult.error) throw new Error(jobResult.error.message); return NextResponse.json({ ...d, upload_jobs: jobResult.data, manifest, map: await mediaUrl(d.map_path), previews: await Promise.all( (manifest.availableViewpoints ?? manifest.viewpoints).map( async (p: { id: string }) => ({ id: p.id, image: d.map_path ? await mediaUrl(`${d.id}/${p.id}/preview.webp`) : null, }), ), ), }); } if (method === "PATCH") { if (!["draft", "rejected"].includes(d.state)) throw new Error("Only private drafts can be edited"); const body = z .object({ name: z.string().min(3).max(80), description: z.string().max(2000), selectedIds: z.array(z.string()).min(5).optional(), }) .parse(await jsonBody(request)); const input = required( await client .from("dataset_inputs") .select("manifest") .eq("dataset_id", d.id) .single(), ); let manifest = { ...datasetSchema.parse(input.manifest), name: body.name, description: body.description, }; if (body.selectedIds) manifest = selectDraftLocations(manifest, body.selectedIds); manifest = datasetSchema.parse(manifest); required( await client .from("dataset_inputs") .update({ manifest }) .eq("dataset_id", d.id) .select("dataset_id") .single(), ); required( await client .from("datasets") .update({ name: body.name, description: body.description, panorama_count: manifest.viewpoints.length, cover_path: `${d.id}/${manifest.viewpoints[0].id}/preview.webp`, state: "draft", rejection_reason: null, updated_at: new Date().toISOString(), }) .eq("id", d.id) .select("id") .single(), ); return NextResponse.json({ ok: true }); } if (method === "POST" && path[2] === "submit") { if (!["draft", "rejected"].includes(d.state) || d.panorama_count < 5) throw new Error( "Need a processed draft with at least five panoramas", ); required( await client .from("datasets") .update({ state: "review" }) .eq("id", d.id) .in("state", ["draft", "rejected"]) .select("id") .single(), ); return NextResponse.json({ ok: true }); } if (method === "POST" && path[2] === "cancel") { if (!["uploading", "processing"].includes(d.state)) throw new Error("This dataset is not uploading or processing"); const cancelled = await client .from("upload_jobs") .update({ state: "cancelled" }) .eq("dataset_id", d.id) .in("state", ["queued", "running"]) .select("id") .maybeSingle(); if (cancelled.error) throw new Error(cancelled.error.message); required( await client .from("datasets") .update({ state: "failed", rejection_reason: "Processing cancelled", }) .eq("id", d.id) .in("state", ["uploading", "processing"]) .select("id") .single(), ); return NextResponse.json({ ok: true }); } } if (path[0] === "tokens") { const owner = await contributor(request); if (method === "GET") return NextResponse.json({ tokens: required( await client .from("contributor_tokens") .select("id,label,revoked,created_at") .eq("owner_id", owner), ), }); if (method === "POST") { const label = z .string() .min(1) .max(80) .parse((await jsonBody(request)).label); await limit("tokens:" + owner, 20, 3600); const token = "mg_" + randomBytes(32).toString("hex"); required( await client .from("contributor_tokens") .insert({ owner_id: owner, token_hash: hash(token), label }) .select("id") .single(), ); return NextResponse.json({ token }); } if (method === "DELETE") { required( await client .from("contributor_tokens") .update({ revoked: true }) .eq("id", uuid(path[1])) .eq("owner_id", owner) .select("id") .single(), ); return NextResponse.json({ ok: true }); } } if (path[0] === "pairing") { if (method === "POST" && path.length === 1) { const deviceCode = randomBytes(32).toString("hex"), code = randomBytes(4).toString("hex").toUpperCase(); await limit( "pairing:" + hash(request.headers.get("x-forwarded-for") || "unknown"), 30, 600, ); required( await client .from("pairing_codes") .insert({ device_hash: hash(deviceCode), user_code: code, expires_at: new Date(Date.now() + 600000).toISOString(), }) .select("user_code") .single(), ); return NextResponse.json({ deviceCode, userCode: code, verificationUri: `${process.env.MINEGEO_URL ? new URL(process.env.MINEGEO_URL).origin : requestOrigin(request)}/pair?code=${code}`, interval: 5, }); } if (method === "POST" && path[1] === "approve") { const owner = await contributor(request); const code = z .string() .regex(/^[A-F0-9]{8}$/) .parse((await jsonBody(request)).code); const token = "mg_" + randomBytes(32).toString("hex"); const { error } = await client.rpc("approve_pairing", { p_code: code, p_owner: owner, p_token: token, p_hash: hash(token), }); if (error) throw new Error(error.message); return NextResponse.json({ ok: true }); } if (method === "POST" && path[1] === "poll") { const code = z .string() .regex(/^[a-f0-9]{64}$/) .parse((await jsonBody(request)).deviceCode); await limit("poll:" + hash(code), 12, 60); const row = required( await client .from("pairing_codes") .select("*") .eq("device_hash", hash(code)) .gt("expires_at", new Date().toISOString()) .eq("consumed", false) .single(), ); if (!row.token_value) return NextResponse.json({ status: "pending" }); required( await client .from("pairing_codes") .update({ consumed: true, token_value: null }) .eq("device_hash", hash(code)) .eq("consumed", false) .select("device_hash") .single(), ); return NextResponse.json({ status: "approved", token: row.token_value, }); } } if (path[0] === "moderation") { const reviewer = await moderator(request); if (method === "GET" && path[2] === "panoramas") { const d = required( await client .from("datasets") .select("id,state") .eq("id", uuid(path[1])) .single(), ); if (!["review", "published"].includes(d.state)) throw new Error("Map is not awaiting review or published"); const input = required( await client .from("dataset_inputs") .select("manifest") .eq("dataset_id", d.id) .single(), ); return panoramaResponse( d.id, datasetSchema.parse(input.manifest).viewpoints, path[3], ); } if (method === "GET") { const maps = required( await client .from("datasets") .select("*") .in("state", ["review", "published"]) .order("created_at"), ); const reports = required( await client.from("reports").select("*").eq("state", "open"), ); return NextResponse.json({ maps: await Promise.all( maps.map(async (m) => ({ ...m, image: await mediaUrl(m.cover_path), map: await mediaUrl(m.map_path), })), ), reports, }); } if (method === "POST") { const b = z .object({ datasetId: idSchema, decision: z.enum(["approve", "reject", "unpublish"]), reason: z.string().max(2000).optional(), }) .parse(await jsonBody(request)); if (b.decision === "reject" && !b.reason?.trim()) throw new Error("Provide a rejection reason"); const { error } = await client.rpc("publish_dataset", { p_dataset: b.datasetId, p_reviewer: reviewer, p_decision: b.decision, p_reason: b.reason ?? null, }); if (error) throw new Error(error.message); return NextResponse.json({ ok: true }); } } if (path[0] === "reports" && method === "POST") { const owner = await contributor(request); await limit("reports:" + owner, 10, 3600); const b = z .object({ datasetId: idSchema, reason: z.string().min(10).max(2000) }) .parse(await jsonBody(request)); required( await client .from("reports") .insert({ dataset_id: b.datasetId, owner_id: owner, reason: b.reason, }) .select("id") .single(), ); return NextResponse.json({ ok: true }); } if (path[0] === "reports" && method === "PATCH") { await moderator(request); const b = z .object({ state: z.literal("resolved") }) .parse(await jsonBody(request)); required( await client .from("reports") .update({ state: b.state }) .eq("id", uuid(path[1])) .select("id") .single(), ); return NextResponse.json({ ok: true }); } return NextResponse.json({ error: "Not found" }, { status: 404 }); } catch (error) { 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; return NextResponse.json({ error: message }, { status }); } } async function panoramaResponse( datasetId: string, locations: { id: string }[], pointId?: string, ) { if (!pointId) return NextResponse.json({ locations: locations.map((p) => p.id) }); if (!locations.some((p) => p.id === pointId)) throw new Error("Location not found"); return NextResponse.json( { faces: await Promise.all( faces.map((f) => mediaUrl(`${datasetId}/${pointId}/${f}.webp`)), ), }, { headers: { "Cache-Control": "no-store" } }, ); }