138 lines
4.4 KiB
TypeScript
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),
|
|
});
|
|
}
|