Files
MineGeo/lib/multiplayer-api.ts

248 lines
7.7 KiB
TypeScript

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 { 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<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 account = await user();
if (!account)
throw new ServiceError("Log in to create a multiplayer room", 401);
const body = createRoomSchema.parse(await jsonBody(request));
await limit("multiplayer:create:" + accessHash, 20, 3600);
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 action = path[2] ?? "view";
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<string, string | number> = {};
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),
});
}