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 { accountProfile } from "./profile"; import { multiplayerAppearance } from "./multiplayer-appearance"; import { mediaUrls } from "./catalog"; import { requestOrigin } from "./request-origin"; import { ServiceError } from "./service-error"; import { limitRoomCreation } from "./request-security"; import { scheduleDiscordCleanup } from "./discord-cleanup-scheduler"; import { discordPartyReady, discordAfterResponse, partyAnnouncement, } from "./discord-announcements"; import { createRoomSchema, joinRoomSchema, roomRoundSchema, roomGuessSchema, chatMessageSchema, type RoomView, type RoomChat, } 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 account = await user(); if (!account) throw new ServiceError("Log in to create a multiplayer room", 401); const body = createRoomSchema.parse(await jsonBody(request)); await limitRoomCreation(account.id, accessHash, limit); const profile = await accountProfile(account); const id = required( await client.rpc("create_map_room", { p_dataset: body.datasetId, p_difficulty: body.difficulty, p_name: profile.display_name, p_limit: body.playerLimit, p_access_hash: accessHash, p_owner: account.id, }), ); return respond({ id }); } const id = z.string().uuid().parse(path[1]); const linked = await client .from("multiplayer_rooms") .select("tournament_id") .eq("id", id) .maybeSingle(); if (linked.error) required(linked); if (linked.data?.tournament_id) throw new ServiceError( "Use the tournament page to join or play this event", 409, ); const action = path[2] ?? "view"; if ( action === "community" && path.length === 3 && ["GET", "POST"].includes(request.method) ) { if (!savedSecret) throw new ServiceError("Room membership permission required", 403); const input = request.method === "POST" ? await jsonBody(request) : { action: "view" }; const schema = z.discriminatedUnion("action", [ z.object({ action: z.literal("view") }).strict(), z .object({ action: z.literal("slow"), seconds: z.number().int().min(0).max(300), }) .strict(), ...(["admin", "unadmin", "kick", "ban"] as const).map((action) => z .object({ action: z.literal(action), playerId: z.string().uuid() }) .strict(), ), z .object({ action: z.literal("unban"), banId: z.string().uuid() }) .strict(), z .object({ action: z.literal("tag"), playerId: z.string().uuid(), tag: z .string() .trim() .max(32) .refine( (value) => !/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test( value, ), ), }) .strict(), ]); const body = schema.parse(input); if (request.method === "POST") { if (body.action === "view") throw new ServiceError("Not found", 404); await limit(`multiplayer:moderation:${accessHash}`, 30, 60); } return respond( required( await client.rpc("party_community", { p_room: id, p_access_hash: accessHash, p_action: body.action, p_payload: body, }), ), ); } if (action === "discord" && path.length === 3 && request.method === "POST") { const view = required( await client.rpc("multiplayer_action", { p_room: id, p_access_hash: accessHash, p_action: "view", p_payload: {}, }), ) as unknown as RoomView; if (!savedSecret || !view.you || view.you !== view.hostId) throw new ServiceError("Only the room owner can publish this party", 403); z.object({}) .strict() .parse(await jsonBody(request)); if (!discordPartyReady()) throw new ServiceError( "Discord party sharing is not configured yet", 503, ); await limit("multiplayer:discord:" + accessHash, 5, 3600); required( await client.rpc("publish_discord_party", { p_room: id, p_access_hash: accessHash, }), ); discordAfterResponse(id); return respond({ ok: true }); } 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); const chat = required( await client.rpc("multiplayer_chat", { p_room: id, p_access_hash: accessHash, p_text: body?.text ?? null, p_client_id: body?.clientId ?? null, }), ) as unknown as RoomChat; const appearances = await multiplayerAppearance( id, chat.messages.map((message) => message.playerId), ); return respond({ ...chat, messages: chat.messages.map((message) => ({ ...message, ...appearances.get(message.playerId), })), }); } let payload: Record = {}; let joiningAccount: string | null = null; 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); const account = await user(); if (account) { const profile = await accountProfile(account); payload.name = profile.display_name; joiningAccount = account.id; } } 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( action === "join" ? await client.rpc("join_multiplayer_room", { p_room: id, p_access_hash: accessHash, p_owner: joiningAccount, p_name: String(payload.name), }) : 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 (room.status === "closed") { try { await scheduleDiscordCleanup(room.id); } catch { console.error("Closed Discord party remains queued for cleanup."); } } if (action !== "media") { const appearances = await multiplayerAppearance( id, room.players.map((player) => player.id), ); room.players = room.players.map((player) => ({ ...player, ...appearances.get(player.id), })); } if (action !== "media") { room.discord = await partyAnnouncement(room); 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), }); }