Files
MineGeo/lib/multiplayer-api.ts

138 lines
4.4 KiB
TypeScript

import "server-only";
import { createHash, randomBytes } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { admin, required } from "./supabase";
import { mediaUrls } from "./catalog";
import { requestOrigin } from "./request-origin";
import { ServiceError } from "./service-error";
import {
createRoomSchema,
joinRoomSchema,
roomRoundSchema,
roomGuessSchema,
type RoomView,
} from "../shared/multiplayer";
import {
datasetSchema,
faces,
panoramaPath,
viewpointSchema,
} from "../shared/protocol";
type Dependencies = {
jsonBody: (request: NextRequest) => Promise<unknown>;
limit: (key: string, count: number, seconds: number) => Promise<void>;
};
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 id = required(
await client.rpc("create_multiplayer_room", {
p_dataset: body.datasetId,
p_difficulty: body.difficulty,
p_name: body.displayName,
p_limit: body.playerLimit,
p_access_hash: accessHash,
}),
);
return respond({ id });
}
const id = z.string().uuid().parse(path[1]);
const action = path[2] ?? "view";
let payload: Record<string, string | number> = {};
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),
});
}