Add docs navigation and tighten API request security

This commit is contained in:
2026-10-11 18:53:46 +05:00
parent a54a3e7705
commit 3cc3136762
28 changed files with 915 additions and 29 deletions

View File

@@ -14,6 +14,8 @@ import {
import { mediaUrl, mediaUrls } from "./catalog";
import { ServiceError, checkServiceError } from "./service-error";
import { requestOrigin, verifyOrigin } from "./request-origin";
import { browserAccount } from "./request-security";
import { jsonBody } from "./json-body";
import { accountApi } from "./account-api";
import { multiplayerApi } from "./multiplayer-api";
import { bugReportApi } from "./bug-report-api";
@@ -45,10 +47,11 @@ const uuid = (value: string) => idSchema.parse(value);
function cookieSecret(request: NextRequest) {
return request.cookies.get("mg_guest")?.value ?? "";
}
async function contributor(request: NextRequest) {
async function contributor(request: NextRequest, browserOnly = false) {
const token = request.headers.get("authorization")?.replace(/^Bearer /, "");
let owner: string | undefined;
if (token) {
if (browserOnly) owner = (await browserAccount(request, user)).id;
else if (token) {
const row = required(
await admin()
.from("contributor_tokens")
@@ -67,7 +70,7 @@ async function contributor(request: NextRequest) {
return owner;
}
async function moderator(request: NextRequest, adminOnly = false) {
const owner = await contributor(request);
const owner = await contributor(request, true);
const profile = required(
await admin().from("profiles").select("role").eq("id", owner).single(),
);
@@ -112,23 +115,6 @@ async function session(request: NextRequest, id: string) {
if (account && record.owner_id === account.id) return record;
throw new Error("Session not found");
}
async function jsonBody(request: NextRequest) {
const reader = request.body?.getReader();
if (!reader) throw new Error("JSON request required");
const chunks: Uint8Array[] = [];
let size = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > 2 * 1024 * 1024) {
await reader.cancel();
throw new Error("Request too large");
}
chunks.push(value);
}
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
export async function handle(request: NextRequest, path: string[]) {
try {
verifyOrigin(request);
@@ -802,7 +788,7 @@ export async function handle(request: NextRequest, path: string[]) {
}
}
if (path[0] === "tokens") {
const owner = await contributor(request);
const owner = await contributor(request, true);
if (method === "GET")
return NextResponse.json({
tokens: required(
@@ -871,7 +857,7 @@ export async function handle(request: NextRequest, path: string[]) {
});
}
if (method === "POST" && path[1] === "approve") {
const owner = await contributor(request);
const owner = await contributor(request, true);
const code = z
.string()
.regex(/^[A-F0-9]{8}$/)

21
lib/json-body.ts Normal file
View File

@@ -0,0 +1,21 @@
import { ServiceError } from "./service-error";
export const jsonBodyLimit = 2 * 1024 * 1024;
export async function jsonBody(request: Pick<Request, "body">) {
const reader = request.body?.getReader();
if (!reader) throw new ServiceError("JSON request required", 400);
const chunks: Uint8Array[] = [];
let size = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > jsonBodyLimit) {
await reader.cancel();
throw new ServiceError("Request too large", 413);
}
chunks.push(value);
}
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}

View File

@@ -8,6 +8,7 @@ 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,
@@ -65,7 +66,7 @@ export async function multiplayerApi(
if (!account)
throw new ServiceError("Log in to create a multiplayer room", 401);
const body = createRoomSchema.parse(await jsonBody(request));
await limit("multiplayer:create:" + accessHash, 20, 3600);
await limitRoomCreation(account.id, accessHash, limit);
const profile = await accountProfile(account);
const id = required(
await client.rpc("create_map_room", {

23
lib/request-security.ts Normal file
View File

@@ -0,0 +1,23 @@
import { ServiceError } from "./service-error";
/** Device upload tokens cannot authorize staff actions or issue other credentials. */
export async function browserAccount<T extends { id: string }>(
request: Pick<Request, "headers">,
getUser: () => Promise<T | null>,
): Promise<T> {
if (request.headers.has("authorization"))
throw new ServiceError("Use your signed-in account for this action", 403);
const account = await getUser();
if (!account) throw new ServiceError("Sign in to continue", 401);
return account;
}
export async function limitRoomCreation(
accountId: string,
accessHash: string,
limit: (key: string, count: number, seconds: number) => Promise<void>,
) {
// A browser cookie is replaceable. The verified account is the durable limit.
await limit("multiplayer:create:account:" + accountId, 20, 3600);
await limit("multiplayer:create:" + accessHash, 20, 3600);
}