The first commit, the core and all files is adding to repo

This commit is contained in:
2026-10-04 14:20:13 +05:00
commit 53d7b263c4
94 changed files with 10668 additions and 0 deletions

671
lib/api.ts Normal file
View File

@@ -0,0 +1,671 @@
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";
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"));
}
function verifyOrigin(request: NextRequest) {
if (request.method === "GET" || request.method === "HEAD") return;
const origin = request.headers.get("origin");
const allowed = new Set([
new URL(request.url).origin,
process.env.MINEGEO_URL || "http://localhost:3000",
]);
if (origin && !allowed.has(origin)) throw new Error("Invalid request origin");
if (
!origin &&
!request.headers.get("authorization") &&
!request.headers.get("content-type")?.includes("application/json")
)
throw new Error("JSON request required");
}
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",
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,
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)")
.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") {
const input = required(
await client
.from("dataset_inputs")
.select("manifest")
.eq("dataset_id", d.id)
.single(),
);
const manifest = datasetSchema.parse(input.manifest);
return NextResponse.json({
...d,
manifest,
map: await mediaUrl(d.map_path),
previews: await Promise.all(
manifest.viewpoints.map(async (p: { id: string }) => ({
id: p.id,
image: await mediaUrl(`${d.id}/${p.id}/preview.webp`),
})),
),
});
}
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.viewpoints = manifest.viewpoints.filter(
(p: { id: string }) => body.selectedIds!.includes(p.id),
);
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,
state: "draft",
rejection_reason: null,
})
.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") {
required(
await client
.from("upload_jobs")
.update({ state: "cancelled" })
.eq("dataset_id", d.id)
.in("state", ["queued", "running"])
.select("id")
.single(),
);
await client
.from("datasets")
.update({ state: "failed", rejection_reason: "Processing cancelled" })
.eq("id", d.id)
.in("state", ["uploading", "processing"]);
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(request.url).origin}/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") {
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 });
}
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 });
}
}