143 lines
5.3 KiB
TypeScript
143 lines
5.3 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { isRequestTimestampFresh, verifyHashedToken } from "@minecraft-account-manager/auth";
|
|
import { problemDetails, velocityConnectionRequestSchema } from "@minecraft-account-manager/contracts";
|
|
import { events, minecraftAccounts, pluginCredentials, pluginRequests } from "@minecraft-account-manager/database";
|
|
import { and, eq, isNull, lt } from "drizzle-orm";
|
|
import { NextResponse } from "next/server";
|
|
import { db } from "@/lib/database";
|
|
import { isUniqueConstraintViolation } from "@/lib/database-errors";
|
|
import { logger } from "@/lib/logger";
|
|
import { problemInstance, problemResponse } from "@/lib/problem-response";
|
|
|
|
const MAX_CLOCK_SKEW_MS = 45_000;
|
|
|
|
function methodNotAllowed(request: Request) {
|
|
const response = problemResponse(problemDetails(
|
|
"urn:error:method-not-allowed",
|
|
"Method not allowed",
|
|
405,
|
|
"This endpoint only accepts POST requests.",
|
|
problemInstance(request),
|
|
));
|
|
response.headers.set("allow", "POST");
|
|
return response;
|
|
}
|
|
|
|
export const GET = methodNotAllowed;
|
|
export const PUT = methodNotAllowed;
|
|
export const PATCH = methodNotAllowed;
|
|
export const DELETE = methodNotAllowed;
|
|
|
|
export async function POST(request: Request) {
|
|
const instance = problemInstance(request);
|
|
const authorization = request.headers.get("authorization") ?? "";
|
|
const token = authorization.startsWith("Bearer ") ? authorization.slice(7).trim() : "";
|
|
if (!token) return problemResponse(problemDetails(
|
|
"urn:error:unauthorized",
|
|
"Unauthorized",
|
|
401,
|
|
"A valid Velocity server credential is required.",
|
|
instance,
|
|
));
|
|
|
|
const mediaType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
|
|
if (mediaType !== "application/json") return problemResponse(problemDetails(
|
|
"urn:error:unsupported-media-type",
|
|
"Unsupported media type",
|
|
415,
|
|
"Velocity connection reports must use application/json.",
|
|
instance,
|
|
));
|
|
|
|
const parsed = velocityConnectionRequestSchema.safeParse(await request.json().catch(() => null));
|
|
if (!parsed.success) return problemResponse(problemDetails(
|
|
"urn:error:invalid-velocity-connection-request",
|
|
"Invalid Velocity connection report",
|
|
400,
|
|
"The request body does not match the required Velocity connection contract.",
|
|
instance,
|
|
{ issues: parsed.error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message, code: issue.code })) },
|
|
));
|
|
|
|
const input = parsed.data;
|
|
const occurredAt = new Date(input.occurredAt);
|
|
if (!isRequestTimestampFresh(occurredAt, new Date(), MAX_CLOCK_SKEW_MS)) return problemResponse(problemDetails(
|
|
"urn:error:expired-velocity-connection-request",
|
|
"Expired Velocity connection report",
|
|
401,
|
|
"The request timestamp is outside the allowed clock-skew window.",
|
|
instance,
|
|
));
|
|
|
|
const [credential] = await db
|
|
.select({ secretHash: pluginCredentials.secretHash })
|
|
.from(pluginCredentials)
|
|
.where(and(eq(pluginCredentials.serverId, input.serverId), isNull(pluginCredentials.revokedAt)))
|
|
.limit(1);
|
|
if (!credential || !verifyHashedToken(token, credential.secretHash)) return problemResponse(problemDetails(
|
|
"urn:error:unauthorized",
|
|
"Unauthorized",
|
|
401,
|
|
"The Velocity server credential is invalid or revoked.",
|
|
instance,
|
|
));
|
|
|
|
try {
|
|
const recorded = await db.transaction(async (tx) => {
|
|
await tx.delete(pluginRequests).where(lt(pluginRequests.expiresAt, new Date()));
|
|
await tx.insert(pluginRequests).values({
|
|
requestId: input.requestId,
|
|
serverId: input.serverId,
|
|
receivedAt: new Date(),
|
|
expiresAt: new Date(Date.now() + 5 * 60_000),
|
|
});
|
|
const [account] = await tx
|
|
.select({ id: minecraftAccounts.id, userId: minecraftAccounts.userId })
|
|
.from(minecraftAccounts)
|
|
.where(and(eq(minecraftAccounts.minecraftUuid, input.minecraftUuid), isNull(minecraftAccounts.deletedAt)))
|
|
.limit(1);
|
|
if (!account) return false;
|
|
|
|
await tx.insert(events).values({
|
|
id: randomUUID(),
|
|
source: `/velocity/${input.serverId}`,
|
|
type: "games.minecraft.account-manager.game.player.connected",
|
|
subject: `minecraft-account/${account.id}`,
|
|
time: occurredAt,
|
|
actorUserId: account.userId,
|
|
correlationId: input.requestId,
|
|
data: {
|
|
username: input.username,
|
|
minecraftUuid: input.minecraftUuid,
|
|
serverId: input.serverId,
|
|
},
|
|
});
|
|
return true;
|
|
});
|
|
if (!recorded) return problemResponse(problemDetails(
|
|
"urn:error:unknown-minecraft-account",
|
|
"Unknown Minecraft account",
|
|
404,
|
|
"The connected Minecraft account is no longer registered.",
|
|
instance,
|
|
));
|
|
return new NextResponse(null, { status: 204 });
|
|
} catch (error) {
|
|
if (isUniqueConstraintViolation(error, "plugin_requests_pkey")) return problemResponse(problemDetails(
|
|
"urn:error:replayed-velocity-connection-request",
|
|
"Velocity request replayed",
|
|
409,
|
|
"This Velocity request ID has already been processed.",
|
|
instance,
|
|
));
|
|
logger.error({ err: error, event: "velocity.connection_report_failed" }, "Failed to record a confirmed Velocity connection");
|
|
return problemResponse(problemDetails(
|
|
"urn:error:service-unavailable",
|
|
"Service unavailable",
|
|
503,
|
|
"The connection report could not be recorded.",
|
|
instance,
|
|
));
|
|
}
|
|
}
|