import "server-only"; import { createHash, randomBytes } from "node:crypto"; import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { admin, required, user } from "./supabase"; import { mediaUrls } from "./catalog"; import { requestOrigin } from "./request-origin"; import { ServiceError } from "./service-error"; import { createRoomSchema, joinRoomSchema, roomRoundSchema, roomGuessSchema, chatMessageSchema, type RoomView, } from "../shared/multiplayer"; import { datasetSchema, faces, panoramaPath, viewpointSchema, } from "../shared/protocol"; type Dependencies = { jsonBody: (request: NextRequest) => Promise; limit: (key: string, count: number, seconds: number) => Promise; }; const hash = (value: string) => createHash("sha256").update(value).digest("hex"); export async function multiplayerApi( request: NextRequest, path: string[], { jsonBody, limit }: Dependencies, ) { const client = admin(); const savedSecret = request.cookies.get("mg_multiplayer")?.value; const secret = savedSecret || randomBytes(32).toString("hex"); const accessHash = hash(secret); const respond = (body: unknown) => { const response = NextResponse.json(body); if (!savedSecret && request.method === "POST") response.cookies.set("mg_multiplayer", secret, { httpOnly: true, sameSite: "lax", secure: requestOrigin(request, process.env.MINEGEO_URL).startsWith( "https:", ), path: "/", maxAge: 60 * 60 * 24 * 30, }); return response; }; if (request.method === "POST" && path.length === 1) { const body = createRoomSchema.parse(await jsonBody(request)); await limit("multiplayer:create:" + accessHash, 20, 3600); const account = await user(); const id = required( await client.rpc("create_map_room", { p_dataset: body.datasetId, p_difficulty: body.difficulty, p_name: body.displayName, p_limit: body.playerLimit, p_access_hash: accessHash, p_owner: account?.id ?? null, }), ); return respond({ id }); } const id = z.string().uuid().parse(path[1]); const action = path[2] ?? "view"; if ( action === "chat" && path.length === 3 && ["GET", "POST"].includes(request.method) ) { if (!savedSecret) throw new ServiceError("Chat permission requires joining this room", 403); const body = request.method === "POST" ? chatMessageSchema.parse(await jsonBody(request)) : null; if (body) await limit("multiplayer:chat:" + accessHash, 60, 60); return respond( required( await client.rpc("multiplayer_chat", { p_room: id, p_access_hash: accessHash, p_text: body?.text ?? null, p_client_id: body?.clientId ?? null, }), ), ); } let payload: Record = {}; if (request.method === "POST" && path.length === 3) { const input = await jsonBody(request); if (action === "join") { payload = { name: joinRoomSchema.parse(input).displayName }; await limit("multiplayer:join:" + accessHash, 40, 3600); } else if (action === "guess") payload = roomGuessSchema.parse(input); else if (action === "ready" || action === "next") payload = roomRoundSchema.parse(input); else if (action === "kick") payload = z.object({ playerId: z.string().uuid() }).parse(input); else if (!["start", "leave", "close"].includes(action)) return NextResponse.json({ error: "Not found" }, { status: 404 }); } else if ( !( request.method === "GET" && (path.length === 2 || (path.length === 3 && action === "media")) ) ) return NextResponse.json({ error: "Not found" }, { status: 404 }); const room = required( await client.rpc("multiplayer_action", { p_room: id, p_access_hash: accessHash, p_action: request.method === "GET" ? "view" : action, p_payload: payload, }), ) as unknown as RoomView; if (action !== "media") return respond(room); if (!room.you) throw new ServiceError("Join this room to play", 403); if (["lobby", "closed"].includes(room.status)) throw new ServiceError("This round is not available yet", 409); const [record, round] = await Promise.all([ client .from("multiplayer_rooms") .select("revision_id") .eq("id", id) .single(), client .from("multiplayer_rounds") .select("viewpoint") .eq("room_id", id) .eq("round_number", room.round) .single(), ]); const revision = required( await client .from("revisions") .select("asset_root,manifest") .eq("id", required(record).revision_id) .single(), ); const manifest = datasetSchema.parse(revision.manifest); const viewpoint = viewpointSchema.parse(required(round).viewpoint); const signed = await mediaUrls([ manifest.mapPath ?? `${revision.asset_root}/map.webp`, ...faces.map((face) => panoramaPath(revision.asset_root, viewpoint, `${face}.webp`), ), ]); return respond({ round: room.round, bounds: manifest.bounds, map: signed[0], faces: signed.slice(1), }); }