Add developer API, Prometheus metrics, and community links
This commit is contained in:
@@ -20,6 +20,7 @@ import { accountApi } from "./account-api";
|
||||
import { multiplayerApi } from "./multiplayer-api";
|
||||
import { bugReportApi } from "./bug-report-api";
|
||||
import { moderatorUsersApi } from "./moderator-users-api";
|
||||
import { prometheusApi } from "./prometheus-api";
|
||||
import { contributionCutoff, reportSchema } from "../shared/moderation";
|
||||
import { siteSettingsSchema } from "../shared/site-settings";
|
||||
import { getSiteSettings } from "./site-settings";
|
||||
@@ -905,6 +906,10 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
}
|
||||
if (path[0] === "moderation") {
|
||||
const { id: reviewer, role } = await moderator(request);
|
||||
if (path[1] === "prometheus" && path.length === 2) {
|
||||
requireAdministrator(role);
|
||||
return await prometheusApi(request, reviewer, jsonBody);
|
||||
}
|
||||
if (path[1] === "discord" && path.length === 2) {
|
||||
requireAdministrator(role);
|
||||
if (method === "GET") return NextResponse.json(await discordStatus());
|
||||
|
||||
148
lib/developer-api.ts
Normal file
148
lib/developer-api.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import "server-only";
|
||||
import { admin, required } from "./supabase";
|
||||
import { developerResponse } from "./developer-response";
|
||||
import {
|
||||
developerPage,
|
||||
type DeveloperPagination,
|
||||
} from "@/shared/developer-api";
|
||||
import type { Database } from "@/shared/database";
|
||||
|
||||
const mapColumns =
|
||||
"id,name,description,source,state,bounds,panorama_count,created_at,updated_at" as const;
|
||||
type MapRecord = Pick<
|
||||
Database["public"]["Tables"]["datasets"]["Row"],
|
||||
| "id"
|
||||
| "name"
|
||||
| "description"
|
||||
| "source"
|
||||
| "state"
|
||||
| "bounds"
|
||||
| "panorama_count"
|
||||
| "created_at"
|
||||
| "updated_at"
|
||||
>;
|
||||
function mapView(map: MapRecord) {
|
||||
return {
|
||||
id: map.id,
|
||||
name: map.name,
|
||||
description: map.description,
|
||||
source: map.source,
|
||||
state: map.state,
|
||||
bounds: map.bounds,
|
||||
panoramaCount: map.panorama_count,
|
||||
createdAt: map.created_at,
|
||||
updatedAt: map.updated_at,
|
||||
url: `/servers/${map.id}`,
|
||||
};
|
||||
}
|
||||
async function maps(page: DeveloperPagination, owner?: string) {
|
||||
let query = admin().from("datasets").select(mapColumns, { count: "exact" });
|
||||
// Public requests cannot reveal private, rejected or banned maps by guessing IDs.
|
||||
query = owner
|
||||
? query.eq("owner_id", owner)
|
||||
: query.eq("state", "published").is("banned_at", null);
|
||||
if (page.q)
|
||||
query = query.ilike("name", `%${page.q.replace(/[\\%_]/g, "\\$&")}%`);
|
||||
const result = await query
|
||||
.order("created_at", { ascending: false })
|
||||
.order("id")
|
||||
.range(page.offset, page.offset + page.limit - 1);
|
||||
const rows = required(result);
|
||||
return {
|
||||
maps: rows.map(mapView),
|
||||
pagination: developerPage(result.count ?? 0, page),
|
||||
};
|
||||
}
|
||||
async function map(id: string) {
|
||||
const result = await admin()
|
||||
.from("datasets")
|
||||
.select(mapColumns)
|
||||
.eq("id", id)
|
||||
.eq("state", "published")
|
||||
.is("banned_at", null)
|
||||
.maybeSingle();
|
||||
if (result.error) required(result);
|
||||
return result.data ? mapView(result.data) : null;
|
||||
}
|
||||
async function account(owner: string) {
|
||||
const result = await admin()
|
||||
.from("profiles")
|
||||
.select(
|
||||
"id,display_name,created_at,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon",
|
||||
)
|
||||
.eq("id", owner)
|
||||
.maybeSingle();
|
||||
if (result.error) required(result);
|
||||
const p = result.data;
|
||||
return p
|
||||
? {
|
||||
id: p.id,
|
||||
displayName: p.display_name,
|
||||
createdAt: p.created_at,
|
||||
appearance: {
|
||||
nameGlow: p.name_glow,
|
||||
glowColor: p.glow_color,
|
||||
bugFinder: p.bug_finder,
|
||||
customPrefix: p.custom_prefix,
|
||||
customPrefixIcon: p.custom_prefix_icon,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
}
|
||||
async function games(owner: string, page: DeveloperPagination) {
|
||||
const result = await admin()
|
||||
.from("game_sessions")
|
||||
.select("id,difficulty,created_at,current_round", { count: "exact" })
|
||||
.eq("owner_id", owner)
|
||||
.order("created_at", { ascending: false })
|
||||
.order("id")
|
||||
.range(page.offset, page.offset + page.limit - 1);
|
||||
const rows = required(result);
|
||||
const complete = rows.filter((s) => s.current_round === 5).map((s) => s.id);
|
||||
const totals = new Map<string, { score: number; count: number }>();
|
||||
if (complete.length) {
|
||||
const rounds = required(
|
||||
await admin()
|
||||
.from("game_rounds")
|
||||
.select("session_id,result")
|
||||
.in("session_id", complete),
|
||||
);
|
||||
for (const round of rounds) {
|
||||
const result = round.result as { score?: unknown } | null;
|
||||
if (
|
||||
!Number.isInteger(result?.score) ||
|
||||
Number(result?.score) < 0 ||
|
||||
Number(result?.score) > 5000
|
||||
)
|
||||
continue;
|
||||
const previous = totals.get(round.session_id) ?? { score: 0, count: 0 };
|
||||
totals.set(round.session_id, {
|
||||
score: previous.score + Number(result?.score),
|
||||
count: previous.count + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
games: rows.map((s) => ({
|
||||
id: s.id,
|
||||
difficulty: s.difficulty,
|
||||
createdAt: s.created_at,
|
||||
currentRound: s.current_round,
|
||||
complete: s.current_round === 5,
|
||||
score: totals.get(s.id)?.count === 5 ? totals.get(s.id)!.score : null,
|
||||
})),
|
||||
pagination: developerPage(result.count ?? 0, page),
|
||||
};
|
||||
}
|
||||
export function developerApi(request: Request, path: string[]) {
|
||||
return developerResponse(request, path, {
|
||||
consume: async (hash) =>
|
||||
required(
|
||||
await admin().rpc("developer_key_consume", { p_token_hash: hash }),
|
||||
),
|
||||
maps,
|
||||
map,
|
||||
account,
|
||||
games,
|
||||
});
|
||||
}
|
||||
137
lib/developer-key-management.ts
Normal file
137
lib/developer-key-management.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import "server-only";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { admin, required, user } from "./supabase";
|
||||
import { accountProfile } from "./profile";
|
||||
import { browserAccount } from "./request-security";
|
||||
import { verifyOrigin } from "./request-origin";
|
||||
import { ServiceError } from "./service-error";
|
||||
import { developerKeyInput } from "@/shared/developer-api";
|
||||
import { developerError, developerJson } from "./developer-response";
|
||||
|
||||
async function body(request: Request) {
|
||||
if (!request.headers.get("content-type")?.includes("application/json"))
|
||||
throw new ServiceError("Use application/json for key management.", 400);
|
||||
const reader = request.body?.getReader();
|
||||
if (!reader) throw new ServiceError("JSON body required.", 400);
|
||||
const chunks: Uint8Array[] = [];
|
||||
let bytes = 0;
|
||||
while (true) {
|
||||
const next = await reader.read();
|
||||
if (next.done) break;
|
||||
bytes += next.value.byteLength;
|
||||
if (bytes > 8192) {
|
||||
await reader.cancel();
|
||||
throw new ServiceError("Request too large.", 413);
|
||||
}
|
||||
chunks.push(next.value);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
} catch {
|
||||
throw new ServiceError("Valid JSON required.", 400);
|
||||
}
|
||||
}
|
||||
export async function developerKeys(request: Request) {
|
||||
try {
|
||||
if (!["GET", "POST", "DELETE"].includes(request.method))
|
||||
return developerError(
|
||||
"method_not_allowed",
|
||||
"Use GET, POST or DELETE.",
|
||||
405,
|
||||
{ Allow: "GET, POST, DELETE" },
|
||||
);
|
||||
// API/device/metrics credentials cannot mint keys, even alongside account cookies.
|
||||
const owner = await browserAccount(request, user);
|
||||
if (request.headers.get("sec-fetch-site") === "cross-site")
|
||||
return developerError(
|
||||
"invalid_origin",
|
||||
"Use the developer panel on MineGeo.",
|
||||
403,
|
||||
);
|
||||
try {
|
||||
verifyOrigin(request);
|
||||
} catch {
|
||||
return developerError(
|
||||
"invalid_origin",
|
||||
"Request origin is not allowed.",
|
||||
403,
|
||||
);
|
||||
}
|
||||
await accountProfile(owner);
|
||||
if (request.method === "GET") {
|
||||
const keys = required(
|
||||
await admin().rpc("developer_keys_list", { p_owner: owner.id }),
|
||||
);
|
||||
return developerJson({ keys });
|
||||
}
|
||||
if (request.method === "DELETE") {
|
||||
const { id } = z
|
||||
.object({ id: z.string().uuid() })
|
||||
.strict()
|
||||
.parse(await body(request));
|
||||
const ok = required(
|
||||
await admin().rpc("developer_key_revoke", {
|
||||
p_owner: owner.id,
|
||||
p_key: id,
|
||||
}),
|
||||
);
|
||||
return ok
|
||||
? developerJson({ ok: true })
|
||||
: developerError("not_found", "API key not found.", 404);
|
||||
}
|
||||
const input = developerKeyInput.parse(await body(request));
|
||||
const allowed = required(
|
||||
await admin().rpc("check_rate", {
|
||||
p_key: `developer:create:${owner.id}`,
|
||||
p_limit: 10,
|
||||
p_seconds: 3600,
|
||||
}),
|
||||
);
|
||||
if (!allowed)
|
||||
return developerError(
|
||||
"rate_limited",
|
||||
"Too many key creations. Try again later.",
|
||||
429,
|
||||
{ "Retry-After": "3600" },
|
||||
);
|
||||
const token = `mg_dev_${randomBytes(32).toString("hex")}`;
|
||||
const result = await admin().rpc("developer_key_create", {
|
||||
p_owner: owner.id,
|
||||
p_name: input.name,
|
||||
p_scopes: input.scopes,
|
||||
p_token_hash: createHash("sha256").update(token).digest("hex"),
|
||||
p_prefix: token.slice(0, 17),
|
||||
p_expires_days: input.expiresDays,
|
||||
});
|
||||
if (
|
||||
result.error?.code === "22023" &&
|
||||
result.error.message === "Maximum 5 active API keys"
|
||||
)
|
||||
return developerError(
|
||||
"key_limit",
|
||||
"You can have at most five active API keys. Revoke an unused key first.",
|
||||
409,
|
||||
);
|
||||
const key = required(result);
|
||||
return developerJson({ key, token }, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError)
|
||||
return developerError(
|
||||
"invalid_request",
|
||||
"Check the key name, permissions and expiry.",
|
||||
400,
|
||||
);
|
||||
if (error instanceof ServiceError)
|
||||
return developerError(
|
||||
error.status === 401 ? "sign_in_required" : "invalid_request",
|
||||
error.message,
|
||||
error.status,
|
||||
);
|
||||
return developerError(
|
||||
"service_unavailable",
|
||||
"API key management is temporarily unavailable.",
|
||||
503,
|
||||
);
|
||||
}
|
||||
}
|
||||
192
lib/developer-response.ts
Normal file
192
lib/developer-response.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
developerScopes,
|
||||
developerPagination,
|
||||
type DeveloperPagination,
|
||||
} from "../shared/developer-api";
|
||||
|
||||
const quota = z.object({
|
||||
limit: z.literal(60),
|
||||
remaining: z.number().int().min(0).max(60),
|
||||
ownerLimit: z.literal(180),
|
||||
ownerRemaining: z.number().int().min(0).max(180),
|
||||
resetAt: z.number().int().positive(),
|
||||
});
|
||||
const authorization = z.discriminatedUnion("ok", [
|
||||
quota.extend({
|
||||
ok: z.literal(true),
|
||||
keyId: z.string().uuid(),
|
||||
ownerId: z.string().uuid(),
|
||||
scopes: z.array(z.enum(developerScopes)),
|
||||
}),
|
||||
z.object({
|
||||
ok: z.literal(false),
|
||||
error: z.enum(["invalid_key", "rate_limited"]),
|
||||
limit: z.literal(60).optional(),
|
||||
remaining: z.number().optional(),
|
||||
ownerLimit: z.literal(180).optional(),
|
||||
ownerRemaining: z.number().optional(),
|
||||
resetAt: z.number().optional(),
|
||||
}),
|
||||
]);
|
||||
export type DeveloperData = {
|
||||
consume(hash: string): Promise<unknown>;
|
||||
maps(page: DeveloperPagination, owner?: string): Promise<unknown>;
|
||||
map(id: string): Promise<unknown | null>;
|
||||
account(owner: string): Promise<unknown | null>;
|
||||
games(owner: string, page: DeveloperPagination): Promise<unknown>;
|
||||
};
|
||||
export function developerJson(
|
||||
body: unknown,
|
||||
status = 200,
|
||||
extra: Record<string, string> = {},
|
||||
) {
|
||||
return Response.json(body, {
|
||||
status,
|
||||
headers: {
|
||||
"Cache-Control": "private, no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
}
|
||||
export function developerError(
|
||||
code: string,
|
||||
message: string,
|
||||
status: number,
|
||||
headers: Record<string, string> = {},
|
||||
) {
|
||||
return developerJson({ error: { code, message } }, status, headers);
|
||||
}
|
||||
export async function developerResponse(
|
||||
request: Request,
|
||||
path: string[],
|
||||
data: DeveloperData,
|
||||
) {
|
||||
let headers: Record<string, string> = {};
|
||||
if (request.method !== "GET")
|
||||
return developerError(
|
||||
"method_not_allowed",
|
||||
"This API version supports GET requests only.",
|
||||
405,
|
||||
{ Allow: "GET" },
|
||||
);
|
||||
const match = /^Bearer (mg_dev_[a-f0-9]{64})$/.exec(
|
||||
request.headers.get("authorization") ?? "",
|
||||
);
|
||||
if (!match)
|
||||
return developerError(
|
||||
"invalid_key",
|
||||
"A valid developer Bearer key is required.",
|
||||
401,
|
||||
{ "WWW-Authenticate": "Bearer" },
|
||||
);
|
||||
try {
|
||||
const auth = authorization.parse(
|
||||
await data.consume(createHash("sha256").update(match[1]).digest("hex")),
|
||||
);
|
||||
if (!auth.ok && auth.error === "invalid_key")
|
||||
return developerError(
|
||||
"invalid_key",
|
||||
"The developer key is invalid, expired, or revoked.",
|
||||
401,
|
||||
{ "WWW-Authenticate": "Bearer" },
|
||||
);
|
||||
const validatedQuota = quota.parse(auth);
|
||||
const reset = Math.max(
|
||||
1,
|
||||
validatedQuota.resetAt - Math.floor(Date.now() / 1000),
|
||||
);
|
||||
headers = {
|
||||
"RateLimit-Limit": "60",
|
||||
"RateLimit-Remaining": String(validatedQuota.remaining),
|
||||
"RateLimit-Reset": String(reset),
|
||||
"X-RateLimit-Reset": String(validatedQuota.resetAt),
|
||||
"X-RateLimit-Owner-Limit": "180",
|
||||
"X-RateLimit-Owner-Remaining": String(validatedQuota.ownerRemaining),
|
||||
};
|
||||
if (!auth.ok)
|
||||
return developerError(
|
||||
"rate_limited",
|
||||
"Request limit reached. Retry after the indicated delay.",
|
||||
429,
|
||||
{ ...headers, "Retry-After": String(reset) },
|
||||
);
|
||||
const joined = path.join("/");
|
||||
const scope =
|
||||
path[0] === "maps"
|
||||
? "maps:read"
|
||||
: joined === "me"
|
||||
? "account:read"
|
||||
: joined === "me/maps"
|
||||
? "account:maps:read"
|
||||
: joined === "me/games"
|
||||
? "account:games:read"
|
||||
: null;
|
||||
if (!scope)
|
||||
return developerError("not_found", "Endpoint not found.", 404, headers);
|
||||
if (!auth.scopes.includes(scope))
|
||||
return developerError(
|
||||
"insufficient_scope",
|
||||
`This key requires the ${scope} permission.`,
|
||||
403,
|
||||
headers,
|
||||
);
|
||||
const url = new URL(request.url);
|
||||
let page: DeveloperPagination;
|
||||
try {
|
||||
page = developerPagination(
|
||||
url,
|
||||
joined === "maps" || joined === "me/maps",
|
||||
);
|
||||
} catch (error) {
|
||||
return developerError(
|
||||
"invalid_request",
|
||||
(error as Error).message,
|
||||
400,
|
||||
headers,
|
||||
);
|
||||
}
|
||||
if (joined === "maps")
|
||||
return developerJson(await data.maps(page), 200, headers);
|
||||
if (path[0] === "maps" && path.length === 2) {
|
||||
if (url.search || !z.string().uuid().safeParse(path[1]).success)
|
||||
return developerError(
|
||||
"invalid_request",
|
||||
"A valid map UUID and no query parameters are required.",
|
||||
400,
|
||||
headers,
|
||||
);
|
||||
const map = await data.map(path[1]);
|
||||
return map
|
||||
? developerJson({ map }, 200, headers)
|
||||
: developerError("not_found", "Published map not found.", 404, headers);
|
||||
}
|
||||
if (joined === "me") {
|
||||
if (url.search)
|
||||
return developerError(
|
||||
"invalid_request",
|
||||
"This endpoint has no query parameters.",
|
||||
400,
|
||||
headers,
|
||||
);
|
||||
const account = await data.account(auth.ownerId);
|
||||
return account
|
||||
? developerJson({ account }, 200, headers)
|
||||
: developerError("not_found", "Account not found.", 404, headers);
|
||||
}
|
||||
if (joined === "me/maps")
|
||||
return developerJson(await data.maps(page, auth.ownerId), 200, headers);
|
||||
if (joined === "me/games")
|
||||
return developerJson(await data.games(auth.ownerId, page), 200, headers);
|
||||
return developerError("not_found", "Endpoint not found.", 404, headers);
|
||||
} catch {
|
||||
return developerError(
|
||||
"service_unavailable",
|
||||
"The developer API is temporarily unavailable.",
|
||||
503,
|
||||
headers,
|
||||
);
|
||||
}
|
||||
}
|
||||
16
lib/forum.ts
Normal file
16
lib/forum.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import "server-only";
|
||||
import forum from "@/shared/forum.json";
|
||||
|
||||
/** Only expose a public HTTPS destination, never embedded credentials. */
|
||||
export function forumUrl() {
|
||||
const value = process.env.MINEGEO_FORUM_URL ?? forum.url;
|
||||
if (!value.trim()) return null;
|
||||
try {
|
||||
const url = new URL(value.trim());
|
||||
if (url.protocol !== "https:" || url.username || url.password || url.port)
|
||||
return null;
|
||||
return url.href;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
38
lib/prometheus-api.ts
Normal file
38
lib/prometheus-api.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import "server-only";
|
||||
import { randomBytes, createHash } from "node:crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { admin, required } from "./supabase";
|
||||
import { requestOrigin } from "./request-origin";
|
||||
import { prometheusAction } from "@/shared/prometheus";
|
||||
|
||||
export async function prometheusApi(
|
||||
request: NextRequest,
|
||||
reviewer: string,
|
||||
jsonBody: (request: NextRequest) => Promise<unknown>,
|
||||
) {
|
||||
const client = admin();
|
||||
const endpoint = `${requestOrigin(request, process.env.MINEGEO_URL)}/api/metrics`;
|
||||
if (request.method === "GET") {
|
||||
const status = required(
|
||||
await client.rpc("prometheus_status", { p_reviewer: reviewer }),
|
||||
);
|
||||
return NextResponse.json({ ...(status as object), endpoint });
|
||||
}
|
||||
if (request.method === "POST") {
|
||||
const { action } = prometheusAction.parse(await jsonBody(request));
|
||||
const token =
|
||||
action === "rotate"
|
||||
? `mg_metrics_${randomBytes(32).toString("hex")}`
|
||||
: null;
|
||||
const status = required(
|
||||
await client.rpc("configure_prometheus", {
|
||||
p_reviewer: reviewer,
|
||||
p_token_hash: token
|
||||
? createHash("sha256").update(token).digest("hex")
|
||||
: null,
|
||||
}),
|
||||
);
|
||||
return NextResponse.json({ ...(status as object), endpoint, token });
|
||||
}
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
48
lib/prometheus-response.ts
Normal file
48
lib/prometheus-response.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { formatMetrics } from "@/shared/prometheus";
|
||||
|
||||
export async function metricsResponse(
|
||||
request: Pick<Request, "headers">,
|
||||
read: (tokenHash: string) => Promise<unknown>,
|
||||
) {
|
||||
const headers = {
|
||||
"Cache-Control": "private, no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
};
|
||||
const match = /^Bearer (mg_metrics_[a-f0-9]{64})$/.exec(
|
||||
request.headers.get("authorization") ?? "",
|
||||
);
|
||||
if (!match)
|
||||
return new Response("Metrics authentication required\n", {
|
||||
status: 401,
|
||||
headers: { ...headers, "WWW-Authenticate": "Bearer" },
|
||||
});
|
||||
try {
|
||||
const start = performance.now();
|
||||
const snapshot = await read(
|
||||
createHash("sha256").update(match[1]).digest("hex"),
|
||||
);
|
||||
if (snapshot === null)
|
||||
return new Response("Metrics authentication required\n", {
|
||||
status: 401,
|
||||
headers: { ...headers, "WWW-Authenticate": "Bearer" },
|
||||
});
|
||||
const body =
|
||||
formatMetrics(snapshot) +
|
||||
"# HELP minegeo_exporter_read_duration_seconds Time to read and validate the database snapshot.\n" +
|
||||
"# TYPE minegeo_exporter_read_duration_seconds gauge\n" +
|
||||
`minegeo_exporter_read_duration_seconds ${(performance.now() - start) / 1000}\n`;
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Type": "text/plain; version=0.0.4; charset=utf-8",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Never return database errors, credentials, or a misleading all-zero snapshot.
|
||||
return new Response("Metrics temporarily unavailable\n", {
|
||||
status: 503,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user