feat(dashboard): refine activity telemetry and maps
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { events, ipIntelligence, ipObservations, minecraftAccounts, users } from "@minecraft-account-manager/database";
|
||||
import { and, count, countDistinct, desc, eq, gte, inArray, isNotNull, sql } from "drizzle-orm";
|
||||
import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft";
|
||||
import { and, count, countDistinct, desc, eq, gte, inArray, isNotNull, isNull, sql } from "drizzle-orm";
|
||||
import Link from "next/link";
|
||||
import { UserWorldMap, type UserMapLocation } from "@/components/user-world-map";
|
||||
import { db } from "@/lib/database";
|
||||
import { fillDailySeries, type DailyCount } from "@/lib/admin-metrics";
|
||||
import { fillDailySeries, mergeRiskActivity, type DailyCount } from "@/lib/admin-metrics";
|
||||
import { parseUserLocation } from "@/lib/user-location-map";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -15,29 +16,33 @@ export default async function AdminDashboardPage() {
|
||||
fourteenDaysAgo.setUTCHours(0, 0, 0, 0);
|
||||
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1_000);
|
||||
|
||||
const [registrationRows, [totals], [monthlyActive], locationRows, riskyActivity, [recentDenials]] = await Promise.all([
|
||||
const [dailyActiveRows, [totals], [monthlyActive], [monthlyAccounts], locationRows, riskyLatestRows, riskySummaryRows, [recentDenials]] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
day: sql<string>`to_char(date_trunc('day', ${users.createdAt} at time zone 'UTC'), 'YYYY-MM-DD')`,
|
||||
count: count(),
|
||||
day: sql<string>`to_char(date_trunc('day', ${ipObservations.observedAt} at time zone 'UTC'), 'YYYY-MM-DD')`,
|
||||
count: countDistinct(ipObservations.userId),
|
||||
})
|
||||
.from(users)
|
||||
.where(gte(users.createdAt, fourteenDaysAgo))
|
||||
.groupBy(sql`date_trunc('day', ${users.createdAt} at time zone 'UTC')`)
|
||||
.orderBy(sql`date_trunc('day', ${users.createdAt} at time zone 'UTC')`),
|
||||
.from(ipObservations)
|
||||
.where(and(gte(ipObservations.observedAt, fourteenDaysAgo), isNotNull(ipObservations.userId)))
|
||||
.groupBy(sql`date_trunc('day', ${ipObservations.observedAt} at time zone 'UTC')`)
|
||||
.orderBy(sql`date_trunc('day', ${ipObservations.observedAt} at time zone 'UTC')`),
|
||||
db.select({ users: count(users.id) }).from(users),
|
||||
db.select({
|
||||
users: countDistinct(ipObservations.userId),
|
||||
accounts: countDistinct(ipObservations.minecraftAccountId),
|
||||
}).from(ipObservations).where(and(
|
||||
gte(ipObservations.observedAt, thirtyDaysAgo),
|
||||
isNotNull(ipObservations.userId),
|
||||
)),
|
||||
db.select({ accounts: countDistinct(events.subject) }).from(events).where(and(
|
||||
eq(events.type, "games.minecraft.account-manager.game.player.connected"),
|
||||
gte(events.time, thirtyDaysAgo),
|
||||
)),
|
||||
db
|
||||
.selectDistinctOn([ipObservations.userId], {
|
||||
userId: ipObservations.userId,
|
||||
name: users.firstName,
|
||||
discordUsername: users.discordUsername,
|
||||
primaryUsername: minecraftAccounts.username,
|
||||
classification: ipObservations.classification,
|
||||
source: ipObservations.source,
|
||||
observedAt: ipObservations.observedAt,
|
||||
@@ -46,6 +51,11 @@ export default async function AdminDashboardPage() {
|
||||
.from(ipObservations)
|
||||
.innerJoin(users, eq(users.id, ipObservations.userId))
|
||||
.innerJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
|
||||
.leftJoin(minecraftAccounts, and(
|
||||
eq(minecraftAccounts.userId, users.id),
|
||||
eq(minecraftAccounts.isPrimary, true),
|
||||
isNull(minecraftAccounts.deletedAt),
|
||||
))
|
||||
.where(and(
|
||||
isNotNull(ipObservations.userId),
|
||||
sql`case when jsonb_typeof(${ipIntelligence.rawResponse}->'location'->'latitude') = 'number' then (${ipIntelligence.rawResponse}->'location'->>'latitude')::double precision between -90 and 90 else false end`,
|
||||
@@ -53,9 +63,9 @@ export default async function AdminDashboardPage() {
|
||||
))
|
||||
.orderBy(ipObservations.userId, desc(ipObservations.observedAt), desc(ipObservations.id)),
|
||||
db
|
||||
.select({
|
||||
.selectDistinctOn([ipObservations.userId], {
|
||||
id: ipObservations.id,
|
||||
classification: ipObservations.classification,
|
||||
classification: ipIntelligence.classification,
|
||||
observedAt: ipObservations.observedAt,
|
||||
source: ipObservations.source,
|
||||
userId: users.id,
|
||||
@@ -64,17 +74,37 @@ export default async function AdminDashboardPage() {
|
||||
accountUsername: minecraftAccounts.username,
|
||||
})
|
||||
.from(ipObservations)
|
||||
.leftJoin(users, eq(users.id, ipObservations.userId))
|
||||
.innerJoin(users, eq(users.id, ipObservations.userId))
|
||||
.leftJoin(minecraftAccounts, eq(minecraftAccounts.id, ipObservations.minecraftAccountId))
|
||||
.where(inArray(ipObservations.classification, ["vpn", "proxy", "tor"]))
|
||||
.orderBy(desc(ipObservations.observedAt))
|
||||
.limit(10),
|
||||
.innerJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
|
||||
.where(and(
|
||||
isNotNull(ipObservations.userId),
|
||||
gte(ipObservations.observedAt, thirtyDaysAgo),
|
||||
inArray(ipIntelligence.classification, ["vpn", "proxy", "tor"]),
|
||||
))
|
||||
.orderBy(ipObservations.userId, desc(ipObservations.observedAt), desc(ipObservations.id)),
|
||||
db
|
||||
.select({
|
||||
userId: ipObservations.userId,
|
||||
count: count(),
|
||||
classifications: sql<string[]>`array_agg(distinct ${ipIntelligence.classification}::text order by ${ipIntelligence.classification}::text)`,
|
||||
sources: sql<string[]>`array_agg(distinct ${ipObservations.source}::text order by ${ipObservations.source}::text)`,
|
||||
})
|
||||
.from(ipObservations)
|
||||
.innerJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
|
||||
.where(and(
|
||||
isNotNull(ipObservations.userId),
|
||||
gte(ipObservations.observedAt, thirtyDaysAgo),
|
||||
inArray(ipIntelligence.classification, ["vpn", "proxy", "tor"]),
|
||||
))
|
||||
.groupBy(ipObservations.userId),
|
||||
db.select({ count: count() }).from(events).where(and(
|
||||
eq(events.type, "games.minecraft.account-manager.game.login.denied"),
|
||||
gte(events.time, oneDayAgo),
|
||||
)),
|
||||
]);
|
||||
const registrations = fillDailySeries(registrationRows as DailyCount[], now, 14);
|
||||
const dailyActive = fillDailySeries(dailyActiveRows as DailyCount[], now, 14);
|
||||
const riskyActivity = mergeRiskActivity(riskyLatestRows, riskySummaryRows).slice(0, 10);
|
||||
const locations = locationRows.flatMap((row): UserMapLocation[] => {
|
||||
const parsed = parseUserLocation(row.intelligence);
|
||||
if (!parsed || !row.userId) return [];
|
||||
@@ -82,6 +112,7 @@ export default async function AdminDashboardPage() {
|
||||
userId: row.userId,
|
||||
name: row.name ?? row.discordUsername,
|
||||
discordUsername: row.discordUsername,
|
||||
nickname: formatManagedDiscordNickname(row.name ?? row.discordUsername, row.primaryUsername ?? null),
|
||||
latitude: parsed.latitude,
|
||||
longitude: parsed.longitude,
|
||||
location: parsed.label,
|
||||
@@ -104,15 +135,15 @@ export default async function AdminDashboardPage() {
|
||||
<section aria-label="Key metrics" className="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Metric label="Registered users" value={totals?.users ?? 0} detail="All time" />
|
||||
<Metric label="Monthly active users" value={monthlyActive?.users ?? 0} detail="Distinct users · 30 days" />
|
||||
<Metric label="Active Minecraft accounts" value={monthlyActive?.accounts ?? 0} detail="Distinct accounts · 30 days" />
|
||||
<Metric label="Active Minecraft accounts" value={monthlyAccounts?.accounts ?? 0} detail="Confirmed connections · 30 days" />
|
||||
<Metric label="Login denials" value={recentDenials?.count ?? 0} detail="Past 24 hours" accent />
|
||||
</section>
|
||||
|
||||
<div className="mt-10 grid gap-8 lg:grid-cols-[1.3fr_0.7fr]">
|
||||
<RegistrationChart data={registrations} />
|
||||
<DailyActiveChart data={dailyActive} />
|
||||
<section className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Network review</p><h2 className="mt-2 font-display text-2xl font-black uppercase">Recent VPN activity</h2></div>
|
||||
<div><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Network review</p><h2 className="mt-2 font-display text-2xl font-black uppercase">Recent risky network activity</h2><p className="mt-2 text-xs text-muted">Collapsed per user across VPN, proxy, and Tor observations from the past 30 days.</p></div>
|
||||
<Link className="font-mono text-[9px] font-bold uppercase underline underline-offset-4" href="/admin/events?category=security">All security events</Link>
|
||||
</div>
|
||||
<div className="mt-5 divide-y divide-line">
|
||||
@@ -121,9 +152,9 @@ export default async function AdminDashboardPage() {
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
{activity.userId ? <Link className="font-mono text-xs font-bold underline decoration-line underline-offset-4" href={`/admin/users/${activity.userId}`}>{activity.firstName ?? activity.discordUsername ?? "Unknown user"}</Link> : <span className="font-mono text-xs font-bold">Unknown user</span>}
|
||||
<p className="mt-1 text-xs text-muted">{activity.accountUsername ?? "No Minecraft account"} · {activity.source}</p>
|
||||
<p className="mt-1 text-xs text-muted">{activity.accountUsername ?? "No Minecraft account"} · {activity.sources.join(" + ")} · {activity.count} {activity.count === 1 ? "observation" : "observations"}</p>
|
||||
</div>
|
||||
<span className="bg-accent px-2 py-1 font-mono text-[9px] font-bold uppercase text-canvas">{activity.classification}</span>
|
||||
<span className="bg-accent px-2 py-1 font-mono text-[9px] font-bold uppercase text-canvas">{activity.classifications.join(" + ")}</span>
|
||||
</div>
|
||||
<time className="mt-2 block font-mono text-[9px] text-muted" dateTime={activity.observedAt.toISOString()}>{activity.observedAt.toISOString()}</time>
|
||||
</article>
|
||||
@@ -146,7 +177,7 @@ function Metric({ label, value, detail, accent = false }: { label: string; value
|
||||
);
|
||||
}
|
||||
|
||||
function RegistrationChart({ data }: { data: DailyCount[] }) {
|
||||
function DailyActiveChart({ data }: { data: DailyCount[] }) {
|
||||
const width = 720;
|
||||
const height = 260;
|
||||
const padding = 32;
|
||||
@@ -159,22 +190,21 @@ function RegistrationChart({ data }: { data: DailyCount[] }) {
|
||||
|
||||
return (
|
||||
<section className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Growth signal</p>
|
||||
<h2 className="mt-2 font-display text-2xl font-black uppercase">New users by day</h2>
|
||||
<svg aria-labelledby="registration-chart-title registration-chart-description" className="mt-6 h-auto w-full" role="img" viewBox={`0 0 ${width} ${height}`}>
|
||||
<title id="registration-chart-title">New user registrations over the last 14 days</title>
|
||||
<desc id="registration-chart-description">Daily registrations range from zero to {maximum}. A text summary follows the chart.</desc>
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Activity signal</p>
|
||||
<h2 className="mt-2 font-display text-2xl font-black uppercase">Daily active users</h2>
|
||||
<svg aria-labelledby="daily-active-chart-title daily-active-chart-description" className="mt-6 h-auto w-full" role="img" viewBox={`0 0 ${width} ${height}`}>
|
||||
<title id="daily-active-chart-title">Daily active users over the last 14 days</title>
|
||||
<desc id="daily-active-chart-description">Distinct daily users range from zero to {maximum}. Date-labelled values follow the chart.</desc>
|
||||
<line stroke="var(--line)" strokeWidth="1" x1={padding} x2={width - padding} y1={height - padding} y2={height - padding} />
|
||||
<polyline fill="none" points={points} stroke="var(--accent)" strokeLinecap="square" strokeLinejoin="miter" strokeWidth="4" />
|
||||
{data.map((entry, index) => {
|
||||
const [x, y] = points.split(" ")[index]!.split(",");
|
||||
return <circle cx={x} cy={y} fill="var(--panel)" key={entry.day} r="5" stroke="var(--ink)" strokeWidth="3"><title>{entry.day}: {entry.count} new users</title></circle>;
|
||||
return <circle cx={x} cy={y} fill="var(--panel)" key={entry.day} r="5" stroke="var(--ink)" strokeWidth="3"><title>{entry.day}: {entry.count} active users</title></circle>;
|
||||
})}
|
||||
</svg>
|
||||
<dl className="mt-4 grid grid-cols-7 gap-2 border-t border-line pt-4 text-center">
|
||||
{data.map((entry) => <div key={entry.day}><dt className="sr-only">{entry.day}</dt><dd className="font-mono text-xs font-bold">{entry.count}</dd></div>)}
|
||||
<dl className="mt-4 grid grid-cols-7 gap-2 border-t border-line pt-4 text-center sm:grid-cols-[repeat(14,minmax(0,1fr))]">
|
||||
{data.map((entry) => <div key={entry.day}><dt className="font-mono text-[8px] text-muted"><time dateTime={entry.day}>{entry.day.slice(5)}</time></dt><dd className="mt-1 font-mono text-xs font-bold">{entry.count}</dd></div>)}
|
||||
</dl>
|
||||
<div aria-hidden="true" className="mt-2 flex justify-between font-mono text-[9px] text-muted"><span>{data[0]?.day}</span><span>{data.at(-1)?.day}</span></div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { hashToken } from "@minecraft-account-manager/auth";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const databaseState = vi.hoisted(() => ({
|
||||
account: { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", userId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" } as { id: string; userId: string } | null,
|
||||
inserts: [] as Record<string, unknown>[],
|
||||
credentialHash: "" as string | null,
|
||||
replay: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/database", () => ({
|
||||
db: {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: async () => databaseState.credentialHash ? [{ secretHash: databaseState.credentialHash }] : [],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
transaction: async (callback: (tx: unknown) => Promise<unknown>) => callback({
|
||||
delete: () => ({ where: async () => undefined }),
|
||||
insert: () => ({
|
||||
values: async (value: Record<string, unknown>) => {
|
||||
if (databaseState.replay && "requestId" in value) {
|
||||
throw { code: "23505", constraint_name: "plugin_requests_pkey" };
|
||||
}
|
||||
databaseState.inserts.push(value);
|
||||
},
|
||||
}),
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: async () => databaseState.account ? [databaseState.account] : [],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { GET, POST } from "./route";
|
||||
|
||||
function validRequest(overrides: Record<string, unknown> = {}) {
|
||||
return new Request("http://localhost/api/velocity/connection", {
|
||||
method: "POST",
|
||||
headers: { authorization: "Bearer valid-token", "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
requestId: "8dd9dbdc-020a-4077-983c-77747522de8f",
|
||||
serverId: "velocity-main",
|
||||
minecraftUuid: "069a79f444e94726a5befca90e38aaf5",
|
||||
username: "Notch",
|
||||
occurredAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("Velocity connection reporting endpoint", () => {
|
||||
beforeEach(() => {
|
||||
databaseState.account = { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", userId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" };
|
||||
databaseState.inserts = [];
|
||||
databaseState.credentialHash = hashToken("valid-token");
|
||||
databaseState.replay = false;
|
||||
});
|
||||
|
||||
it("rejects methods other than POST with Problem Details", async () => {
|
||||
const response = GET(new Request("http://localhost/api/velocity/connection"));
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.get("content-type")).toContain("application/problem+json");
|
||||
expect(response.headers.get("allow")).toBe("POST");
|
||||
});
|
||||
|
||||
it("requires a server credential", async () => {
|
||||
const response = await POST(new Request("http://localhost/api/velocity/connection", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "{}",
|
||||
}));
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("validates the report before database access", async () => {
|
||||
const response = await POST(new Request("http://localhost/api/velocity/connection", {
|
||||
method: "POST",
|
||||
headers: { authorization: "Bearer test", "content-type": "application/json" },
|
||||
body: JSON.stringify({ username: "bad name" }),
|
||||
}));
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.json()).resolves.toMatchObject({ type: "urn:error:invalid-velocity-connection-request", status: 400 });
|
||||
});
|
||||
|
||||
it("rejects invalid or revoked server credentials", async () => {
|
||||
databaseState.credentialHash = null;
|
||||
const response = await POST(validRequest());
|
||||
expect(response.status).toBe(401);
|
||||
expect(databaseState.inserts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects stale reports before recording them", async () => {
|
||||
const response = await POST(validRequest({ occurredAt: "2026-01-01T00:00:00.000Z" }));
|
||||
expect(response.status).toBe(401);
|
||||
expect(databaseState.inserts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("authenticates and atomically records a confirmed account connection", async () => {
|
||||
const response = await POST(validRequest());
|
||||
expect(response.status).toBe(204);
|
||||
expect(databaseState.inserts).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ requestId: "8dd9dbdc-020a-4077-983c-77747522de8f", serverId: "velocity-main" }),
|
||||
expect.objectContaining({
|
||||
type: "games.minecraft.account-manager.game.player.connected",
|
||||
subject: "minecraft-account/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
actorUserId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it("rejects replayed request IDs", async () => {
|
||||
databaseState.replay = true;
|
||||
const response = await POST(validRequest());
|
||||
expect(response.status).toBe(409);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
type: "urn:error:replayed-velocity-connection-request",
|
||||
status: 409,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not record an event for an unknown account", async () => {
|
||||
databaseState.account = null;
|
||||
const response = await POST(validRequest());
|
||||
expect(response.status).toBe(404);
|
||||
expect(databaseState.inserts).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
@import "leaflet/dist/leaflet.css";
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme inline {
|
||||
@@ -64,6 +65,15 @@ svg a:focus .map-marker {
|
||||
stroke-width: 6px;
|
||||
}
|
||||
|
||||
.map-marker-tooltip {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.map-marker-link:hover .map-marker-tooltip,
|
||||
.map-marker-link:focus .map-marker-tooltip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent);
|
||||
color: var(--panel);
|
||||
|
||||
Reference in New Issue
Block a user