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 { tournamentApi } from "./tournament-api"; import { tournamentDiscordAfterResponse } from "./tournament-discord-server"; 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] === "tournaments") return await tournamentApi(request, path, { db: admin(), account: async () => { const account = await user(); if (account) await accountProfile(account); return account; }, jsonBody, limit, mediaUrls, announce: tournamentDiscordAfterResponse, serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone, }); 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,pan,compass,coordinates,map_zoom,map_pan,timer_visible,scoring", ) .order("name", { ascending: true }), ); return NextResponse.json({ presets: presets.map( ({ map_zoom, map_pan, timer_visible, ...preset }) => ({ ...preset, mapZoom: map_zoom, mapPan: map_pan, timerVisible: timer_visible, }), ), }); } 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 { mapZoom, mapPan, timerVisible, ...fields } = body; const { data, error } = await client .from("difficulty_presets") .insert({ ...fields, map_zoom: mapZoom, map_pan: mapPan, timer_visible: timerVisible, }) .select( "id,name,seconds,zoom,pan,compass,coordinates,map_zoom,map_pan,timer_visible,scoring", ) .single(); checkDifficultyPresetError(error); return NextResponse.json({ preset: data ? { ...data, mapZoom: data.map_zoom, mapPan: data.map_pan, timerVisible: data.timer_visible, } : null, }); } } 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", "void"]), 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)[k] === (d.bounds as Record)[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); const parent = d.parent_dataset_id ? required( await client .from("datasets") .select("owner_id,state,update_state") .eq("id", d.parent_dataset_id) .single(), ) : null; if (needsProcessing(jobResult.data)) processUploadAfterResponse(d.id); return NextResponse.json({ ...d, ownsPublishedParent: !!parent && parent.owner_id === d.owner_id && parent.state === "published", 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(2559) .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] === "publish-own-contribution") { const owner = await contributor(request, true); if (owner !== d.owner_id) throw new ServiceError("Map owner permission required", 403); checkServiceError( ( await client.rpc("publish_own_contribution", { p_contribution: d.id, p_owner: owner, }) ).error, ); return NextResponse.json({ ok: true }); } if (method === "POST" && path[2] === "publish-update") { const owner = await contributor(request, true); if (owner !== d.owner_id) throw new ServiceError("Map owner permission required", 403); checkServiceError( ( await client.rpc("publish_owner_update", { p_dataset: d.id, p_owner: owner, }) ).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[], 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" } }, ); }