All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 12m17s
318 lines
9.7 KiB
TypeScript
318 lines
9.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 { 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<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 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<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),
|
|
});
|
|
}
|