Files
MineGeo/lib/api.ts
pavel444-byte e1a6ab5730
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m22s
Add map community reactions, comments, and moderation
2026-10-17 12:45:18 +05:00

1370 lines
46 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, assertAccountActive } from "./supabase";
import { accountControlsApi } from "./account-controls-api";
import {
datasetSchema,
faces,
customDifficultySchema,
parseUploadManifest,
panoramaPath,
viewpointSchema,
} from "../shared/protocol";
import { mediaUrl, mediaUrls } from "./catalog";
import {
ServiceError,
checkServiceError,
checkDifficultyPresetError,
} from "./service-error";
import { requestOrigin, verifyOrigin } from "./request-origin";
import { browserAccount } from "./request-security";
import { jsonBody } from "./json-body";
import { accountApi } from "./account-api";
import { accountProfile } from "./profile";
import { communityApi } from "./community-api";
import { multiplayerApi } from "./multiplayer-api";
import { bugReportApi } from "./bug-report-api";
import { moderatorUsersApi } from "./moderator-users-api";
import { prometheusApi } from "./prometheus-api";
import { contributionCutoff, reportSchema } from "../shared/moderation";
import { siteSettingsSchema } from "../shared/site-settings";
import { getSiteSettings } from "./site-settings";
import {
canReviewMaps,
isAdministrator,
type StaffRole,
} from "@/shared/permissions";
import { processUploadAfterResponse } from "./process-uploads";
import {
discordAfterResponse,
discordStatus,
checkDiscordSetup,
} from "./discord-announcements";
import { needsProcessing } from "./fabric-jobs";
import { archiveMetadataSchema, importManifest } from "../shared/map-archive";
import {
archiveSignature,
verifyArchiveSignature,
} from "./map-archive-signature";
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, browserOnly = false) {
const token = request.headers.get("authorization")?.replace(/^Bearer /, "");
let owner: string | undefined;
if (browserOnly) owner = (await browserAccount(request, user)).id;
else 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");
await assertAccountActive(owner);
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, adminOnly = false) {
const owner = await contributor(request, true);
const profile = required(
await admin().from("profiles").select("role").eq("id", owner).single(),
);
if (!canReviewMaps(profile.role))
throw new ServiceError("Moderator permission required", 403);
if (adminOnly) requireAdministrator(profile.role);
return { id: owner, role: profile.role };
}
function requireAdministrator(role: StaffRole) {
if (!isAdministrator(role))
throw new ServiceError("Admin permission required", 403);
}
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);
const dataset = required(
await admin()
.from("datasets")
.select("*")
.eq("id", uuid(id))
.eq("owner_id", owner)
.single(),
);
// The ownership predicate above proves this is a current account's map.
return { ...dataset, owner_id: owner };
}
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");
}
export async function handle(request: NextRequest, path: string[]) {
try {
verifyOrigin(request);
if (
(path[0] === "ban" && path[1] === "appeal") ||
(path[0] === "moderation" &&
["accounts", "account-appeals"].includes(path[1]))
)
return await accountControlsApi(request, path, { jsonBody, limit });
if (path[0] === "auth" || path[0] === "profile")
return await accountApi(request, path, { jsonBody, limit });
// Check signed-in identities before any guest-cookie fallback (games/rooms).
if (path[0] !== "health") await user();
if (
path[0] === "maps" ||
path[0] === "community" ||
(path[0] === "moderation" && path[1] === "community-reports")
)
return await communityApi(request, path, {
db: admin(),
getUser: user,
jsonBody,
limit,
getProfile: async () => {
const account = await user();
if (!account) throw new ServiceError("Sign in to continue", 401);
return accountProfile(account);
},
});
if (path[0] === "rooms")
return await multiplayerApi(request, path, { jsonBody, limit });
if (path[0] === "bug-reports")
return await bugReportApi(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] === "difficulty-presets" && path.length === 1) {
if (method === "GET") {
const presets = required(
await client
.from("difficulty_presets")
.select("id,name,seconds,zoom")
.order("name", { ascending: true }),
);
return NextResponse.json({ presets });
}
if (method === "POST") {
const body = customDifficultySchema.parse(await jsonBody(request));
const address =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
"unknown";
await limit("difficulty-presets:" + hash(address), 8, 3600);
const { data, error } = await client
.from("difficulty_presets")
.insert(body)
.select("id,name,seconds,zoom")
.single();
checkDifficultyPresetError(error);
return NextResponse.json({ preset: data });
}
}
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.union([z.enum(["easy", "normal", "hard"]), idSchema]),
})
.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,
difficultySettings: s.difficulty_settings,
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 || (path[1] === "import" && path.length === 2))
) {
await limit("upload:" + owner, 10, 3600);
const body = await jsonBody(request);
const manifest =
path[1] === "import"
? importManifest(body.manifest)
: parseUploadManifest(body.manifest);
const archive =
path[1] === "import"
? archiveMetadataSchema.parse(
(({ assets: _assets, ...metadata }) => metadata)(body.manifest),
)
: null;
if (archive)
verifyArchiveSignature(archive, process.env.SUPABASE_SECRET_KEY!);
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(archive ? "create_map_import" : "create_upload", {
p_owner: owner,
p_manifest: manifest,
...(archive
? {
p_original_map: archive.originalMapId,
p_original_owner: archive.originalOwner,
}
: {}),
}),
);
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,
}),
);
if (state === "processing") processUploadAfterResponse(d.id);
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(id,state,progress,error,updated_at,lease_until,kind,source_dataset_id)",
)
.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) => {
if (needsProcessing(m.upload_jobs))
processUploadAfterResponse(m.id);
return {
...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] === "export" && path.length === 3) {
if (
d.imported_from_id ||
d.parent_dataset_id ||
d.banned_at ||
!d.map_path ||
!["draft", "rejected", "review", "published", "unpublished"].includes(
d.state,
)
)
throw new Error("This map is not ready to export");
await limit("export:" + d.owner_id, 10, 3600);
const input = required(
await client
.from("dataset_inputs")
.select("manifest,validated")
.eq("dataset_id", d.id)
.single(),
);
if (!input.validated)
throw new Error("This map is not ready to export");
const manifest = datasetSchema.parse(input.manifest);
const profile = required(
await client
.from("profiles")
.select("display_name")
.eq("id", d.owner_id)
.single(),
);
const metadata = archiveMetadataSchema.parse({
format: "minegeo-map/v1",
minecraftVersion: manifest.minecraftVersion,
dimension: manifest.dimension,
north: manifest.north,
source: manifest.source,
name: manifest.name,
description: manifest.description,
serverName: manifest.serverName,
bounds: manifest.bounds,
viewpoints: manifest.viewpoints.map(({ id, x, y, z }) => ({
id,
x,
y,
z,
})),
originalMapId: d.id,
originalOwner: profile.display_name || "Explorer",
signature: "0".repeat(64),
});
metadata.signature = archiveSignature(
metadata,
process.env.SUPABASE_SECRET_KEY!,
);
const assets = [
{ path: "map.webp", storage: manifest.mapPath ?? d.map_path },
...manifest.viewpoints.flatMap((p) =>
faces.map((f) => ({
path: `${p.id}/${f}.webp`,
storage: panoramaPath(d.id, p, `${f}.webp`),
})),
),
];
const urls = await mediaUrls(assets.map((a) => a.storage));
return NextResponse.json({
manifest: metadata,
assets: assets.map((a, i) => ({ path: a.path, url: urls[i] })),
});
}
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,
);
processUploadAfterResponse(d.id);
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(
"id,state,progress,error,updated_at,lease_until,kind,source_dataset_id",
)
.eq("dataset_id", d.id)
.maybeSingle();
checkServiceError(jobResult.error);
if (needsProcessing(jobResult.data)) processUploadAfterResponse(d.id);
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") {
if (d.imported_from_id)
throw new ServiceError(
`Owned by "${d.imported_owner_name}". Shared maps cannot be submitted for moderator review.`,
403,
);
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)
.filter((id): id is string => id !== null),
),
]),
)
: [];
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, true);
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, true);
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 { id: reviewer, role } = await moderator(request);
if (path[1] === "prometheus" && path.length === 2) {
requireAdministrator(role);
return await prometheusApi(request, reviewer, jsonBody);
}
if (path[1] === "discord" && path.length === 2) {
requireAdministrator(role);
if (method === "GET") return NextResponse.json(await discordStatus());
if (method === "POST") {
const body = z
.object({ action: z.enum(["check", "retry"]) })
.strict()
.parse(await jsonBody(request));
if (body.action === "check")
return NextResponse.json(await checkDiscordSetup());
required(
await client.rpc("retry_discord_announcements", {
p_reviewer: reviewer,
}),
);
required(
await client
.from("discord_announcements")
.update({ cleanup_lease_until: null })
.not("cleanup_error", "is", null)
.is("cleanup_run_id", null)
.is("deleted_at", null)
.select("id"),
);
discordAfterResponse();
return NextResponse.json({ ok: true });
}
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (path[1] === "users") {
requireAdministrator(role);
return await moderatorUsersApi(request, path, reviewer, jsonBody);
}
if (path[1] === "settings" && path.length === 2 && method === "PATCH") {
requireAdministrator(role);
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") {
requireAdministrator(role);
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)
) {
requireAdministrator(role);
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" &&
[3, 4].includes(path.length)
) {
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");
if (
!isAdministrator(role) &&
!(
d.state === "review" ||
(d.state === "published" && d.update_state === "review")
)
)
throw new ServiceError("Map is not awaiting review", 403);
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" && path.length === 1) {
let mapQuery = client
.from("datasets")
.select("*")
.is("parent_dataset_id", null)
.in("state", ["review", "published", "unpublished"])
.order("created_at");
const adminView = request.nextUrl.searchParams.get("view") === "admin";
if (adminView) requireAdministrator(role);
if (!adminView)
mapQuery = mapQuery.or(
"state.eq.review,and(state.eq.published,update_state.eq.review)",
);
const maps = required(await mapQuery);
const reports = adminView
? required(
await client
.from("reports")
.select("*")
.eq("state", "open")
.order("created_at"),
)
: [];
const appeals = adminView
? 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({
role,
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" && path.length === 1) {
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 === "unpublish") requireAdministrator(role);
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);
if (b.decision === "approve") discordAfterResponse();
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 { id: reviewer } = await moderator(request, true);
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" } },
);
}