All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 12m17s
262 lines
7.9 KiB
TypeScript
262 lines
7.9 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { z } from "zod";
|
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
import type { Database } from "@/shared/database";
|
|
import {
|
|
tournamentEdit,
|
|
tournamentSettings,
|
|
type TournamentView,
|
|
} from "@/shared/tournaments";
|
|
import {
|
|
datasetSchema,
|
|
faces,
|
|
panoramaPath,
|
|
viewpointSchema,
|
|
} from "@/shared/protocol";
|
|
import {
|
|
chatMessageSchema,
|
|
roomGuessSchema,
|
|
roomRoundSchema,
|
|
} from "@/shared/multiplayer";
|
|
import { ServiceError, checkServiceError } from "./service-error";
|
|
|
|
type Dependencies = {
|
|
db: SupabaseClient<Database>;
|
|
account: () => Promise<{ id: string } | null>;
|
|
jsonBody: (request: NextRequest) => Promise<unknown>;
|
|
limit: (key: string, count: number, seconds: number) => Promise<void>;
|
|
mediaUrls: (paths: (string | null)[]) => Promise<(string | null)[]>;
|
|
announce: (target?: string) => void;
|
|
serverTimezone: string;
|
|
};
|
|
function unwrap<T>(result: {
|
|
data: T;
|
|
error: { message: string; code?: string } | null;
|
|
}): NonNullable<T> {
|
|
checkServiceError(result.error);
|
|
if (result.data == null) throw new ServiceError("Tournament not found", 404);
|
|
return result.data;
|
|
}
|
|
export async function tournamentApi(
|
|
request: NextRequest,
|
|
path: string[],
|
|
deps: Dependencies,
|
|
) {
|
|
const { db } = deps;
|
|
const respond = (value: unknown) =>
|
|
NextResponse.json(value, { headers: { "Cache-Control": "no-store" } });
|
|
if (path.length === 1 && request.method === "GET") {
|
|
const tournaments = unwrap(
|
|
await db
|
|
.from("tournaments")
|
|
.select(
|
|
"id,name,owner_id,starts_at,server_timezone,player_limit,round_count,round_time_seconds,timer_mode,status,announce_discord,finished_at,winner_id",
|
|
)
|
|
.order("starts_at", { ascending: false })
|
|
.limit(100),
|
|
);
|
|
return respond({
|
|
tournaments,
|
|
serverNow: Date.now(),
|
|
serverTimezone: deps.serverTimezone,
|
|
});
|
|
}
|
|
if (path.length === 1 && request.method === "POST") {
|
|
const account = await deps.account();
|
|
if (!account) throw new ServiceError("Sign in to create a tournament", 401);
|
|
const body = tournamentSettings.parse(await deps.jsonBody(request));
|
|
const start = Date.parse(body.startsAt),
|
|
now = Date.now();
|
|
if (start <= now || start > now + 90 * 86400000)
|
|
throw new ServiceError(
|
|
"Schedule the tournament within the next 90 days",
|
|
400,
|
|
);
|
|
await deps.limit("tournaments:create:" + account.id, 5, 86400);
|
|
const id = unwrap(
|
|
await db.rpc("create_tournament", {
|
|
p_owner: account.id,
|
|
p_payload: { ...body, serverTimezone: deps.serverTimezone },
|
|
}),
|
|
);
|
|
deps.announce(String(id));
|
|
return respond({ id });
|
|
}
|
|
if (path.length < 2 || path.length > 3)
|
|
throw new ServiceError("Not found", 404);
|
|
const id = z.string().uuid().parse(path[1]);
|
|
const action = path[2] ?? "view";
|
|
if (["community", "chat"].includes(action)) {
|
|
if (!["GET", "POST"].includes(request.method))
|
|
throw new ServiceError("Not found", 404);
|
|
const account = await deps.account();
|
|
if (!account)
|
|
throw new ServiceError("Sign in to join or play a tournament", 401);
|
|
if (action === "chat") {
|
|
const body =
|
|
request.method === "POST"
|
|
? chatMessageSchema.strict().parse(await deps.jsonBody(request))
|
|
: null;
|
|
if (body) await deps.limit(`tournaments:chat:${account.id}`, 60, 60);
|
|
return respond(
|
|
unwrap(
|
|
await db.rpc("tournament_chat", {
|
|
p_tournament: id,
|
|
p_account: account.id,
|
|
p_text: body?.text ?? null,
|
|
p_client_id: body?.clientId ?? null,
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
const schema = z.discriminatedUnion("action", [
|
|
z
|
|
.object({
|
|
action: z.literal("slow"),
|
|
seconds: z.number().int().min(0).max(300),
|
|
})
|
|
.strict(),
|
|
...(["admin", "unadmin", "kick", "ban", "unban"] as const).map((action) =>
|
|
z
|
|
.object({ action: z.literal(action), accountId: z.string().uuid() })
|
|
.strict(),
|
|
),
|
|
z
|
|
.object({
|
|
action: z.literal("tag"),
|
|
accountId: 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 =
|
|
request.method === "POST"
|
|
? schema.parse(await deps.jsonBody(request))
|
|
: { action: "view" as const };
|
|
if (request.method === "POST")
|
|
await deps.limit(`tournaments:moderation:${account.id}`, 30, 60);
|
|
return respond(
|
|
unwrap(
|
|
await db.rpc("tournament_community", {
|
|
p_tournament: id,
|
|
p_account: account.id,
|
|
p_action: body.action,
|
|
p_payload: body,
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
const read =
|
|
request.method === "GET" && (action === "view" || action === "media");
|
|
const write =
|
|
request.method === "POST" &&
|
|
[
|
|
"join",
|
|
"leave",
|
|
"cancel",
|
|
"edit",
|
|
"start",
|
|
"ready",
|
|
"guess",
|
|
"next",
|
|
].includes(action);
|
|
if (!read && !write) throw new ServiceError("Not found", 404);
|
|
const account = await deps.account();
|
|
if (!account && (write || action === "media"))
|
|
throw new ServiceError("Sign in to join or play a tournament", 401);
|
|
let payload = {};
|
|
if (write) {
|
|
const body = await deps.jsonBody(request);
|
|
payload =
|
|
action === "guess"
|
|
? roomGuessSchema
|
|
.extend({ tournamentRound: z.number().int().min(0).max(9) })
|
|
.strict()
|
|
.parse(body)
|
|
: ["ready", "next"].includes(action)
|
|
? roomRoundSchema
|
|
.extend({ tournamentRound: z.number().int().min(0).max(9) })
|
|
.strict()
|
|
.parse(body)
|
|
: action === "edit"
|
|
? tournamentEdit.parse(body)
|
|
: z.object({}).strict().parse(body);
|
|
await deps.limit(
|
|
`tournaments:${action}:${account!.id}`,
|
|
action === "guess" || action === "ready" ? 180 : 30,
|
|
60,
|
|
);
|
|
}
|
|
const view = unwrap(
|
|
await db.rpc("tournament_action", {
|
|
p_tournament: id,
|
|
p_account: account?.id ?? null,
|
|
p_action: read ? "view" : action,
|
|
p_payload: payload,
|
|
}),
|
|
) as unknown as TournamentView;
|
|
if (write && action === "edit") deps.announce(id);
|
|
if (action !== "media") return respond(view);
|
|
const requestedRound = Number(
|
|
z
|
|
.string()
|
|
.regex(/^[0-9]$/)
|
|
.parse(request.nextUrl.searchParams.get("tournamentRound") ?? undefined),
|
|
);
|
|
if (requestedRound !== view.currentRound)
|
|
throw new ServiceError(
|
|
"The tournament advanced. Reconnect to load the current round.",
|
|
409,
|
|
);
|
|
const room = view.currentRoom;
|
|
if (
|
|
!room?.you ||
|
|
!room.id ||
|
|
!["loading", "playing", "results", "finished"].includes(room.status)
|
|
)
|
|
throw new ServiceError("This round is not available yet", 409);
|
|
const [record, point] = await Promise.all([
|
|
db
|
|
.from("multiplayer_rooms")
|
|
.select("revision_id")
|
|
.eq("id", room.id)
|
|
.single(),
|
|
db
|
|
.from("multiplayer_rounds")
|
|
.select("viewpoint")
|
|
.eq("room_id", room.id)
|
|
.eq("round_number", room.round)
|
|
.single(),
|
|
]);
|
|
const revision = unwrap(
|
|
await db
|
|
.from("revisions")
|
|
.select("asset_root,manifest")
|
|
.eq("id", unwrap(record).revision_id)
|
|
.single(),
|
|
);
|
|
const manifest = datasetSchema.parse(revision.manifest);
|
|
const viewpoint = viewpointSchema.parse(unwrap(point).viewpoint);
|
|
const signed = await deps.mediaUrls([
|
|
manifest.mapPath ?? `${revision.asset_root}/map.webp`,
|
|
...faces.map((face) =>
|
|
panoramaPath(revision.asset_root, viewpoint, `${face}.webp`),
|
|
),
|
|
]);
|
|
return respond({
|
|
round: room.round,
|
|
map: signed[0],
|
|
bounds: manifest.bounds,
|
|
faces: signed.slice(1),
|
|
});
|
|
}
|