1117 lines
37 KiB
TypeScript
1117 lines
37 KiB
TypeScript
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,
|
|
parseUploadManifest,
|
|
panoramaPath,
|
|
viewpointSchema,
|
|
} from "../shared/protocol";
|
|
import { mediaUrl, mediaUrls } from "./catalog";
|
|
import { ServiceError, checkServiceError } from "./service-error";
|
|
import { requestOrigin, verifyOrigin } from "./request-origin";
|
|
import { accountApi } from "./account-api";
|
|
import { contributionCutoff, reportSchema } from "../shared/moderation";
|
|
import { siteSettingsSchema } from "../shared/site-settings";
|
|
import { getSiteSettings } from "./site-settings";
|
|
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);
|
|
if (path[0] === "auth" || path[0] === "profile")
|
|
return await accountApi(request, path, { jsonBody, limit });
|
|
const client = admin(),
|
|
method = request.method;
|
|
if (path[0] === "site-settings" && path.length === 1 && method === "GET") {
|
|
return NextResponse.json(await getSiteSettings(), {
|
|
headers: { "Cache-Control": "no-store" },
|
|
});
|
|
}
|
|
if (path[0] === "health" && path.length === 1 && method === "GET") {
|
|
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" && path.length === 1) {
|
|
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: 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" && path.length === 2) {
|
|
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"),
|
|
);
|
|
const signed = await mediaUrls([
|
|
manifest.mapPath ?? `${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,
|
|
difficulty: s.difficulty,
|
|
round: s.current_round,
|
|
bounds: manifest.bounds,
|
|
name: manifest.name,
|
|
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" && path.length === 3) {
|
|
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" && path.length === 3) {
|
|
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 = parseUploadManifest(
|
|
(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 datasetId = required(
|
|
await client.rpc("create_upload", {
|
|
p_owner: owner,
|
|
p_manifest: manifest,
|
|
}),
|
|
);
|
|
return NextResponse.json({ datasetId });
|
|
}
|
|
|
|
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 serverCheck = await client.rpc("check_contribution_server", {
|
|
p_dataset: d.id,
|
|
p_owner: owner,
|
|
});
|
|
checkServiceError(serverCheck.error);
|
|
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!dataset_id(state,progress,error,updated_at,lease_until,kind)",
|
|
)
|
|
.eq("owner_id", owner)
|
|
.or(
|
|
`parent_dataset_id.is.null,created_at.gt.${contributionCutoff()}`,
|
|
)
|
|
.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 (path[2] === "appeals" && path.length === 3 && !d.parent_dataset_id) {
|
|
if (method === "GET") {
|
|
const appeals = required(
|
|
await client
|
|
.from("map_appeals")
|
|
.select("*")
|
|
.eq("dataset_id", d.id)
|
|
.order("created_at", { ascending: false }),
|
|
);
|
|
return NextResponse.json({ appeals });
|
|
}
|
|
if (method === "POST") {
|
|
const body = z
|
|
.object({ reason: z.string().trim().min(10).max(2000) })
|
|
.parse(await jsonBody(request));
|
|
await limit("appeals:" + d.owner_id, 5, 3600);
|
|
const id = required(
|
|
await client.rpc("appeal_map_ban", {
|
|
p_dataset: d.id,
|
|
p_owner: d.owner_id,
|
|
p_reason: body.reason,
|
|
}),
|
|
);
|
|
return NextResponse.json({ id });
|
|
}
|
|
}
|
|
if (path[2] === "refresh-map") {
|
|
if (d.parent_dataset_id)
|
|
throw new Error("Only maps can refresh their guessing map");
|
|
if (method === "POST") {
|
|
const body = z
|
|
.object({ sourceId: idSchema })
|
|
.parse(await jsonBody(request));
|
|
await limit("map-refresh:" + d.owner_id, 6, 3600);
|
|
checkServiceError(
|
|
(
|
|
await client.rpc("refresh_map", {
|
|
p_dataset: d.id,
|
|
p_owner: d.owner_id,
|
|
p_source: body.sourceId,
|
|
})
|
|
).error,
|
|
);
|
|
return NextResponse.json({ ok: true });
|
|
}
|
|
if (method === "GET") {
|
|
const sources = required(
|
|
await client
|
|
.from("datasets")
|
|
.select(
|
|
"id,name,bounds,source,created_at,owner_id,parent_dataset_id,contribution_status",
|
|
)
|
|
.or("owner_id.eq." + d.owner_id + ",parent_dataset_id.eq." + d.id)
|
|
.not("map_path", "is", null)
|
|
.in("state", ["draft", "rejected", "review", "published"])
|
|
.order("created_at", { ascending: false }),
|
|
);
|
|
return NextResponse.json({
|
|
sources: sources
|
|
.filter(
|
|
(s) =>
|
|
(s.owner_id === d.owner_id ||
|
|
s.contribution_status === "accepted") &&
|
|
["minX", "minZ", "maxX", "maxZ"].every(
|
|
(k) =>
|
|
(s.bounds as Record<string, number>)[k] ===
|
|
(d.bounds as Record<string, number>)[k],
|
|
),
|
|
)
|
|
.map((s) => ({
|
|
id: s.id,
|
|
name: s.name,
|
|
source: s.source,
|
|
created_at: s.created_at,
|
|
})),
|
|
});
|
|
}
|
|
}
|
|
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,kind")
|
|
.eq("dataset_id", d.id)
|
|
.maybeSingle();
|
|
checkServiceError(jobResult.error);
|
|
return NextResponse.json({
|
|
...d,
|
|
upload_jobs: jobResult.data,
|
|
name: manifest.name,
|
|
description: manifest.description,
|
|
panorama_count: manifest.viewpoints.length,
|
|
manifest,
|
|
map: await mediaUrl(manifest.mapPath ?? d.map_path),
|
|
previews: await Promise.all(
|
|
(manifest.availableViewpoints ?? manifest.viewpoints).map(
|
|
async (p, index) => ({
|
|
id: p.id,
|
|
label: `Location ${index + 1} · ${p.source ?? manifest.source}`,
|
|
image: d.map_path
|
|
? await mediaUrl(panoramaPath(d.id, p, "preview.webp"))
|
|
: null,
|
|
}),
|
|
),
|
|
),
|
|
});
|
|
}
|
|
if (method === "PATCH" && path[2] === "settings") {
|
|
const body = z
|
|
.object({
|
|
allowContributions: z.boolean().optional(),
|
|
contributionServerAddress: z
|
|
.string()
|
|
.max(255)
|
|
.nullable()
|
|
.optional(),
|
|
})
|
|
.refine(
|
|
(b) => Object.keys(b).length === 1,
|
|
"Change one setting at a time",
|
|
)
|
|
.parse(await jsonBody(request));
|
|
const { error } =
|
|
body.allowContributions !== undefined
|
|
? await client.rpc("set_contributions", {
|
|
p_dataset: d.id,
|
|
p_owner: d.owner_id,
|
|
p_allowed: body.allowContributions,
|
|
})
|
|
: await client.rpc("set_contribution_server", {
|
|
p_dataset: d.id,
|
|
p_owner: d.owner_id,
|
|
p_address: body.contributionServerAddress ?? null,
|
|
});
|
|
checkServiceError(error);
|
|
return NextResponse.json({ ok: true });
|
|
}
|
|
if (method === "PATCH") {
|
|
const body = z
|
|
.object({
|
|
name: z.string().min(3).max(80),
|
|
description: z.string().max(2000),
|
|
selectedIds: z.array(z.string()).min(1).max(24),
|
|
})
|
|
.parse(await jsonBody(request));
|
|
const { error } = await client.rpc("save_draft", {
|
|
p_dataset: d.id,
|
|
p_owner: d.owner_id,
|
|
p_name: body.name,
|
|
p_description: body.description,
|
|
p_ids: body.selectedIds,
|
|
});
|
|
checkServiceError(error);
|
|
return NextResponse.json({ ok: true });
|
|
}
|
|
if (method === "POST" && path[2] === "submit") {
|
|
const { error } = await client.rpc("submit_draft", {
|
|
p_dataset: d.id,
|
|
p_owner: d.owner_id,
|
|
});
|
|
checkServiceError(error);
|
|
return NextResponse.json({ ok: true });
|
|
}
|
|
if (method === "POST" && path[2] === "cancel") {
|
|
if (d.parent_dataset_id) {
|
|
const { error } = await client.rpc("withdraw_contribution", {
|
|
p_dataset: d.id,
|
|
p_owner: d.owner_id,
|
|
});
|
|
checkServiceError(error);
|
|
return NextResponse.json({ ok: true });
|
|
}
|
|
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();
|
|
checkServiceError(cancelled.error);
|
|
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] === "contributions") {
|
|
const owner = await contributor(request),
|
|
target = uuid(path[1]);
|
|
if (method === "GET" && path[2] === "server" && path.length === 3) {
|
|
return NextResponse.json(
|
|
required(
|
|
await client.rpc("contribution_server", {
|
|
p_dataset: target,
|
|
p_owner: owner,
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
if (method === "GET" && path.length === 2) {
|
|
const info = required(
|
|
await client.rpc("contribution_capacity", {
|
|
p_dataset: target,
|
|
p_owner: owner,
|
|
}),
|
|
);
|
|
return NextResponse.json(info);
|
|
}
|
|
const parent = await ownedDataset(request, target);
|
|
if (parent.parent_dataset_id) throw new Error("Choose the original map");
|
|
if (method === "GET" && path[2] === "inbox" && path.length === 3) {
|
|
const rows = required(
|
|
await client
|
|
.from("datasets")
|
|
.select("*")
|
|
.eq("parent_dataset_id", target)
|
|
.gt("created_at", contributionCutoff())
|
|
.order("created_at", { ascending: false }),
|
|
);
|
|
const profiles = rows.length
|
|
? required(
|
|
await client
|
|
.from("profiles")
|
|
.select("id,display_name")
|
|
.in("id", [...new Set(rows.map((c) => c.owner_id))]),
|
|
)
|
|
: [];
|
|
return NextResponse.json({
|
|
contributions: await Promise.all(
|
|
rows.map(async (c) => ({
|
|
...c,
|
|
contributorName:
|
|
profiles.find((p) => p.id === c.owner_id)?.display_name ??
|
|
"Explorer",
|
|
image: await mediaUrl(c.cover_path),
|
|
})),
|
|
),
|
|
});
|
|
}
|
|
const child = required(
|
|
await client
|
|
.from("datasets")
|
|
.select("*")
|
|
.eq("id", uuid(path[3]))
|
|
.eq("parent_dataset_id", target)
|
|
.single(),
|
|
);
|
|
if (method === "GET" && path[4] === "panoramas") {
|
|
const input = required(
|
|
await client
|
|
.from("dataset_inputs")
|
|
.select("manifest")
|
|
.eq("dataset_id", child.id)
|
|
.single(),
|
|
);
|
|
return panoramaResponse(
|
|
child.id,
|
|
datasetSchema.parse(input.manifest).viewpoints,
|
|
path[5],
|
|
);
|
|
}
|
|
if (method === "POST" && path[2] === "inbox") {
|
|
const body = z
|
|
.object({
|
|
decision: z.enum(["accept", "reject"]),
|
|
reason: z.string().max(2000).optional(),
|
|
})
|
|
.parse(await jsonBody(request));
|
|
const { error } = await client.rpc("review_contribution", {
|
|
p_dataset: target,
|
|
p_contribution: child.id,
|
|
p_owner: owner,
|
|
p_decision: body.decision,
|
|
p_reason: body.reason ?? undefined,
|
|
});
|
|
checkServiceError(error);
|
|
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),
|
|
});
|
|
checkServiceError(error);
|
|
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 (path[1] === "settings" && path.length === 2 && method === "PATCH") {
|
|
const body = siteSettingsSchema.parse(await jsonBody(request));
|
|
const enabled = required(await client.rpc("set_beta_badge", {
|
|
p_reviewer: reviewer, p_enabled: body.betaBadgeEnabled,
|
|
}));
|
|
return NextResponse.json({ betaBadgeEnabled: enabled }, {
|
|
headers: { "Cache-Control": "no-store" },
|
|
});
|
|
}
|
|
if (path[1] === "appeals" && path.length === 3 && method === "POST") {
|
|
const body = z
|
|
.object({
|
|
decision: z.enum(["approve", "reject"]),
|
|
reason: z.string().trim().min(10).max(2000),
|
|
})
|
|
.parse(await jsonBody(request));
|
|
checkServiceError(
|
|
(
|
|
await client.rpc("review_map_appeal", {
|
|
p_appeal: uuid(path[2]),
|
|
p_reviewer: reviewer,
|
|
p_decision: body.decision,
|
|
p_reason: body.reason,
|
|
})
|
|
).error,
|
|
);
|
|
return NextResponse.json({ ok: true });
|
|
}
|
|
if (
|
|
path[1] === "reports" &&
|
|
path[3] === "panoramas" &&
|
|
method === "GET" &&
|
|
[4, 5].includes(path.length)
|
|
) {
|
|
const report = required(
|
|
await client
|
|
.from("reports")
|
|
.select("*")
|
|
.eq("id", uuid(path[2]))
|
|
.single(),
|
|
);
|
|
const revision = required(
|
|
report.revision_id
|
|
? await client
|
|
.from("revisions")
|
|
.select("manifest,asset_root")
|
|
.eq("id", report.revision_id)
|
|
.single()
|
|
: await client
|
|
.from("revisions")
|
|
.select("manifest,asset_root")
|
|
.eq("dataset_id", report.dataset_id)
|
|
.order("created_at", { ascending: false })
|
|
.limit(1)
|
|
.single(),
|
|
);
|
|
const manifest = datasetSchema.parse(revision.manifest);
|
|
return panoramaResponse(
|
|
revision.asset_root,
|
|
manifest.viewpoints,
|
|
path[4],
|
|
);
|
|
}
|
|
if (method === "GET" && path[2] === "panoramas") {
|
|
const d = required(
|
|
await client
|
|
.from("datasets")
|
|
.select("*")
|
|
.eq("id", uuid(path[1]))
|
|
.single(),
|
|
);
|
|
if (!["review", "published", "unpublished"].includes(d.state))
|
|
throw new Error("Map is not awaiting review or published");
|
|
if (d.parent_dataset_id)
|
|
throw new Error("Contributions require owner approval first");
|
|
const input = required(
|
|
(d.state === "published" && d.update_state !== "review") ||
|
|
d.state === "unpublished"
|
|
? await client
|
|
.from("revisions")
|
|
.select("manifest")
|
|
.eq("id", d.revision_id!)
|
|
.single()
|
|
: 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("*")
|
|
.is("parent_dataset_id", null)
|
|
.in("state", ["review", "published", "unpublished"])
|
|
.order("created_at"),
|
|
);
|
|
const reports = required(
|
|
await client
|
|
.from("reports")
|
|
.select("*")
|
|
.eq("state", "open")
|
|
.order("created_at"),
|
|
);
|
|
const appeals = required(
|
|
await client
|
|
.from("map_appeals")
|
|
.select("*")
|
|
.eq("state", "open")
|
|
.order("created_at"),
|
|
);
|
|
const reportViews = await Promise.all(
|
|
reports.map(async (report) => {
|
|
const revision = report.revision_id
|
|
? required(
|
|
await client
|
|
.from("revisions")
|
|
.select("manifest,asset_root")
|
|
.eq("id", report.revision_id)
|
|
.single(),
|
|
)
|
|
: null;
|
|
const manifest = revision
|
|
? datasetSchema.parse(revision.manifest)
|
|
: null;
|
|
return {
|
|
...report,
|
|
mapName:
|
|
maps.find((m) => m.id === report.dataset_id)?.name ??
|
|
"Reported map",
|
|
map: revision
|
|
? await mediaUrl(
|
|
manifest?.mapPath ?? `${revision.asset_root}/map.webp`,
|
|
)
|
|
: null,
|
|
};
|
|
}),
|
|
);
|
|
return NextResponse.json({
|
|
maps: await Promise.all(
|
|
maps.map(async (m) => {
|
|
const input =
|
|
m.update_state === "review"
|
|
? required(
|
|
await client
|
|
.from("dataset_inputs")
|
|
.select("manifest")
|
|
.eq("dataset_id", m.id)
|
|
.single(),
|
|
)
|
|
: null;
|
|
const pending = input
|
|
? datasetSchema.parse(input.manifest)
|
|
: null;
|
|
return {
|
|
...m,
|
|
name: pending?.name ?? m.name,
|
|
description: pending?.description ?? m.description,
|
|
panorama_count: pending?.viewpoints.length ?? m.panorama_count,
|
|
image: await mediaUrl(m.cover_path),
|
|
map: await mediaUrl(pending?.mapPath ?? m.map_path),
|
|
};
|
|
}),
|
|
),
|
|
reports: reportViews,
|
|
appeals,
|
|
});
|
|
}
|
|
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,
|
|
});
|
|
checkServiceError(error);
|
|
return NextResponse.json({ ok: true });
|
|
}
|
|
}
|
|
if (path[0] === "reports" && path.length === 1 && method === "POST") {
|
|
const owner = await contributor(request);
|
|
await limit("reports:" + owner, 10, 3600);
|
|
const b = reportSchema.parse(await jsonBody(request));
|
|
let datasetId = b.datasetId,
|
|
revisionId: string | undefined,
|
|
panoramaId: string | null = null;
|
|
if (b.sessionId) {
|
|
const game = await session(request, b.sessionId);
|
|
const revision = required(
|
|
await client
|
|
.from("revisions")
|
|
.select("dataset_id,id")
|
|
.eq("id", game.revision_id)
|
|
.single(),
|
|
);
|
|
datasetId = revision.dataset_id;
|
|
revisionId = revision.id;
|
|
if (b.target === "panorama") {
|
|
if (b.round! > game.current_round)
|
|
throw new Error("This round is not available yet");
|
|
const round = required(
|
|
await client
|
|
.from("game_rounds")
|
|
.select("viewpoint")
|
|
.eq("session_id", game.id)
|
|
.eq("round_number", b.round!)
|
|
.single(),
|
|
);
|
|
panoramaId = viewpointSchema.parse(round.viewpoint).id;
|
|
}
|
|
} else {
|
|
const map = required(
|
|
await client
|
|
.from("datasets")
|
|
.select("revision_id")
|
|
.eq("id", datasetId!)
|
|
.eq("state", "published")
|
|
.is("parent_dataset_id", null)
|
|
.single(),
|
|
);
|
|
revisionId = map.revision_id ?? undefined;
|
|
}
|
|
const id = required(
|
|
await client.rpc("submit_content_report", {
|
|
p_dataset: datasetId!,
|
|
p_owner: owner,
|
|
p_revision: revisionId!,
|
|
p_panorama: panoramaId,
|
|
p_category: b.category,
|
|
p_reason: b.reason,
|
|
p_evidence: b.evidenceUrl ?? null,
|
|
}),
|
|
);
|
|
return NextResponse.json({ id });
|
|
}
|
|
if (path[0] === "reports" && path.length === 2 && method === "PATCH") {
|
|
const reviewer = await moderator(request);
|
|
const b = z
|
|
.object({
|
|
decision: z.enum(["confirm", "dismiss"]),
|
|
reason: z.string().trim().min(10).max(2000),
|
|
})
|
|
.parse(await jsonBody(request));
|
|
checkServiceError(
|
|
(
|
|
await client.rpc("review_content_report", {
|
|
p_report: uuid(path[1]),
|
|
p_reviewer: reviewer,
|
|
p_decision: b.decision,
|
|
p_reason: b.reason,
|
|
})
|
|
).error,
|
|
);
|
|
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 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 });
|
|
}
|
|
}
|
|
|
|
async function panoramaResponse(
|
|
datasetId: string,
|
|
locations: z.infer<typeof viewpointSchema>[],
|
|
pointId?: string,
|
|
) {
|
|
if (!pointId)
|
|
return NextResponse.json({
|
|
locations: locations.map((p) => p.id),
|
|
labels: Object.fromEntries(
|
|
locations.map((p, index) => [
|
|
p.id,
|
|
`Location ${index + 1}${p.source ? ` · ${p.source}` : ""}`,
|
|
]),
|
|
),
|
|
});
|
|
const point = locations.find((p) => p.id === pointId);
|
|
if (!point) throw new Error("Location not found");
|
|
return NextResponse.json(
|
|
{
|
|
faces: await Promise.all(
|
|
faces.map((f) => mediaUrl(panoramaPath(datasetId, point, `${f}.webp`))),
|
|
),
|
|
},
|
|
{ headers: { "Cache-Control": "no-store" } },
|
|
);
|
|
}
|