Add account restrictions, appeals, and admin moderation tools

This commit is contained in:
2026-10-13 12:32:37 +05:00
parent 018be5049e
commit cfe5820802
28 changed files with 1936 additions and 125 deletions

View File

@@ -1,7 +1,14 @@
import "server-only";
import { createClient } from "@supabase/supabase-js";
import { NextRequest, NextResponse } from "next/server";
import { admin, authClient, required, user } from "./supabase";
import {
admin,
authClient,
required,
user,
rawUser,
accountState,
} from "./supabase";
import { accountProfile } from "./profile";
import {
authActionSchema,
@@ -26,12 +33,31 @@ export async function accountApi(
path: string[],
context: Context,
) {
if (
path[0] === "auth" &&
path[1] === "logout" &&
path.length === 2 &&
request.method === "POST"
) {
const client = await authClient();
const { error } = await client.auth.signOut({ scope: "local" });
if (error) throw new Error("Could not sign out. Please try again.");
const response = json({ ok: true });
for (const name of ["mg_guest", "mg_multiplayer"])
response.cookies.set(name, "", {
path: "/",
maxAge: 0,
httpOnly: true,
sameSite: "lax",
});
return response;
}
if (
path[0] === "auth" &&
path[1] === "initial-password" &&
path.length === 2
) {
const account = await user();
const account = await rawUser();
if (!account?.email || !account.email_confirmed_at)
return json({ error: "Log in to manage your account" }, 401);
if (request.method === "GET") {
@@ -126,7 +152,7 @@ export async function accountApi(
: next,
);
if (body.action === "login") {
const { error } = await client.auth.signInWithPassword({
const { data, error } = await client.auth.signInWithPassword({
email: body.email,
password: body.password,
});
@@ -140,7 +166,12 @@ export async function accountApi(
},
401,
);
return json({ next });
return json({
next:
data.user && (await accountState(data.user.id)) !== "active"
? "/ban/appeal"
: next,
});
}
if (body.action === "signup") {
const { data, error } = await client.auth.signUp({
@@ -175,7 +206,7 @@ export async function accountApi(
});
}
if (path[1] !== "password") return json({ error: "Not found" }, 404);
const account = await user();
const account = await rawUser();
if (!account?.email)
return json({ error: "Log in before changing your password" }, 401);
const body = passwordUpdateSchema.parse(await context.jsonBody(request));

205
lib/account-controls-api.ts Normal file
View File

@@ -0,0 +1,205 @@
import "server-only";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { admin, required, user, rawUser } from "./supabase";
import { browserAccount } from "./request-security";
import { ServiceError, checkServiceError } from "./service-error";
import { canReviewMaps, isAdministrator } from "@/shared/permissions";
import {
accountActionInput,
accountAppealInput,
accountAppealReviewInput,
} from "@/shared/account-controls";
import { discordAfterResponse } from "./discord-announcements";
type Context = {
jsonBody: (r: NextRequest) => Promise<unknown>;
limit: (key: string, count: number, seconds: number) => Promise<void>;
};
const columns =
"id,display_name,role,account_status,account_reason,account_changed_at,account_status_version" as const;
const respond = (data: unknown, status = 200) =>
NextResponse.json(data, {
status,
headers: { "Cache-Control": "private, no-store" },
});
function accountError(error: { code?: string; message: string } | null) {
if (error && ["42501", "22023", "P0002"].includes(error.code ?? ""))
throw new ServiceError(
error.message,
error.code === "42501" ? 403 : error.code === "P0002" ? 404 : 400,
);
checkServiceError(error);
}
export async function accountControlsApi(
request: NextRequest,
path: string[],
{ jsonBody, limit }: Context,
) {
const db = admin();
if (path[0] === "ban" && path[1] === "appeal" && path.length === 2) {
// A restricted identity may access only its own status and appeal history.
const account = await browserAccount(request, rawUser);
const profile = required(
await db.from("profiles").select(columns).eq("id", account.id).single(),
);
if (request.method === "GET") {
const appeals = required(
await db
.from("account_appeals")
.select(
"id,owner_id,status_version,reason,state,created_at,review_reason,reviewed_at",
)
.eq("owner_id", account.id)
.order("created_at", { ascending: false })
.limit(50),
);
return respond({
account: { ...profile, status_version: profile.account_status_version },
appeals: appeals.map((a) => ({
...a,
display_name: profile.display_name,
})),
});
}
if (request.method === "POST") {
const body = accountAppealInput.parse(await jsonBody(request));
await limit("account-appeal:" + account.id, 5, 3600);
const submission = await db.rpc("submit_account_appeal", {
p_owner: account.id,
p_reason: body.reason,
});
accountError(submission.error);
const id = required(submission);
return respond({ id }, 201);
}
return respond({ error: "Method not allowed" }, 405);
}
const reviewer = await browserAccount(request, user);
const staff = required(
await db
.from("profiles")
.select("role,account_status")
.eq("id", reviewer.id)
.single(),
);
if (!canReviewMaps(staff.role) || staff.account_status !== "active")
throw new ServiceError("Moderator permission required", 403);
if (path[1] === "accounts") {
if (!isAdministrator(staff.role))
throw new ServiceError("Admin permission required", 403);
if (path.length === 2 && request.method === "GET") {
const search = z
.string()
.trim()
.max(80)
.parse(request.nextUrl.searchParams.get("q") ?? "");
let query = db
.from("profiles")
.select(columns)
.order("display_name")
.order("id")
.limit(30);
if (search)
query = z.string().uuid().safeParse(search).success
? query.eq("id", search)
: query.ilike(
"display_name",
"%" + search.replace(/[\\%_]/g, "\\$&") + "%",
);
return respond({
users: required(await query).map((p) => ({
...p,
status_version: p.account_status_version,
})),
viewerId: reviewer.id,
});
}
if (path.length === 3 && request.method === "POST") {
const target = z.string().uuid().parse(path[2]);
const body = accountActionInput.parse(await jsonBody(request));
if (body.action === "delete" && body.confirmation !== target)
throw new ServiceError(
"Type the account ID to confirm permanent deletion",
400,
);
await limit("account-actions:" + reviewer.id, 30, 3600);
const action = await db.rpc("admin_account_action", {
p_reviewer: reviewer.id,
p_user: target,
p_action: body.action === "delete" ? "prepare_delete" : body.action,
p_reason: body.reason,
});
accountError(action.error);
const result = required(action);
// Restriction and credential invalidation commit before Auth deletion. A
// failed delete leaves a blocked account that an Admin can retry safely.
if (body.action === "delete") {
const { error } = await db.auth.admin.deleteUser(target, false);
if (error)
throw new ServiceError(
"Account is blocked, but deletion could not finish. Retry permanent deletion.",
503,
);
const remaining = await db
.from("profiles")
.select("id")
.eq("id", target)
.maybeSingle();
if (remaining.error || remaining.data)
throw new ServiceError(
"Deletion could not be confirmed. Refresh the account list before retrying.",
503,
);
}
discordAfterResponse();
return respond({ ok: true, result });
}
}
if (path[1] === "account-appeals") {
if (path.length === 2 && request.method === "GET") {
const appeals = required(
await db
.from("account_appeals")
.select(
"id,owner_id,status_version,reason,state,created_at,review_reason,reviewed_at",
)
.eq("state", "open")
.order("created_at")
.limit(100),
);
const profiles = appeals.length
? required(
await db
.from("profiles")
.select("id,display_name,account_reason")
.in("id", [...new Set(appeals.map((a) => a.owner_id))]),
)
: [];
const names = new Map(profiles.map((p) => [p.id, p.display_name]));
const reasons = new Map(profiles.map((p) => [p.id, p.account_reason]));
return respond({
appeals: appeals.map((a) => ({
...a,
display_name: names.get(a.owner_id) ?? "Deleted player",
ban_reason: reasons.get(a.owner_id) ?? null,
})),
});
}
if (path.length === 3 && request.method === "POST") {
const id = z.string().uuid().parse(path[2]);
const body = accountAppealReviewInput.parse(await jsonBody(request));
await limit("account-appeal-review:" + reviewer.id, 60, 3600);
const review = await db.rpc("review_account_appeal", {
p_reviewer: reviewer.id,
p_appeal: id,
p_decision: body.decision,
p_reason: body.reason,
});
accountError(review.error);
return respond({ ok: true });
}
}
return respond({ error: "Not found" }, 404);
}

View File

@@ -2,7 +2,8 @@ import "server-only";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { admin, required, user } from "./supabase";
import { admin, required, user, assertAccountActive } from "./supabase";
import { accountControlsApi } from "./account-controls-api";
import {
datasetSchema,
faces,
@@ -64,6 +65,7 @@ async function contributor(request: NextRequest, browserOnly = false) {
owner = row.owner_id;
} else owner = (await user())?.id;
if (!owner) throw new Error("Sign in to continue");
await assertAccountActive(owner);
const { error } = await admin()
.from("profiles")
.upsert({ id: owner }, { onConflict: "id", ignoreDuplicates: true });
@@ -97,7 +99,7 @@ async function limit(key: string, count: number, seconds: number) {
}
async function ownedDataset(request: NextRequest, id: string) {
const owner = await contributor(request);
return required(
const dataset = required(
await admin()
.from("datasets")
.select("*")
@@ -105,6 +107,8 @@ async function ownedDataset(request: NextRequest, id: string) {
.eq("owner_id", owner)
.single(),
);
// The ownership predicate above proves this is a current account's map.
return { ...dataset, owner_id: owner };
}
async function session(request: NextRequest, id: string) {
const record = required(
@@ -119,8 +123,16 @@ async function session(request: NextRequest, id: string) {
export async function handle(request: NextRequest, path: string[]) {
try {
verifyOrigin(request);
if (
(path[0] === "ban" && path[1] === "appeal") ||
(path[0] === "moderation" &&
["accounts", "account-appeals"].includes(path[1]))
)
return await accountControlsApi(request, path, { jsonBody, limit });
if (path[0] === "auth" || path[0] === "profile")
return await accountApi(request, path, { jsonBody, limit });
// Check signed-in identities before any guest-cookie fallback (games/rooms).
if (path[0] !== "health") await user();
if (path[0] === "rooms")
return await multiplayerApi(request, path, { jsonBody, limit });
if (path[0] === "bug-reports")
@@ -733,7 +745,13 @@ export async function handle(request: NextRequest, path: string[]) {
await client
.from("profiles")
.select("id,display_name")
.in("id", [...new Set(rows.map((c) => c.owner_id))]),
.in("id", [
...new Set(
rows
.map((c) => c.owner_id)
.filter((id): id is string => id !== null),
),
]),
)
: [];
return NextResponse.json({
@@ -1066,12 +1084,14 @@ export async function handle(request: NextRequest, path: string[]) {
.is("parent_dataset_id", null)
.in("state", ["review", "published", "unpublished"])
.order("created_at");
if (!isAdministrator(role))
const adminView = request.nextUrl.searchParams.get("view") === "admin";
if (adminView) requireAdministrator(role);
if (!adminView)
mapQuery = mapQuery.or(
"state.eq.review,and(state.eq.published,update_state.eq.review)",
);
const maps = required(await mapQuery);
const reports = isAdministrator(role)
const reports = adminView
? required(
await client
.from("reports")
@@ -1080,7 +1100,7 @@ export async function handle(request: NextRequest, path: string[]) {
.order("created_at"),
)
: [];
const appeals = isAdministrator(role)
const appeals = adminView
? required(
await client
.from("map_appeals")

View File

@@ -16,7 +16,7 @@ export type MapListing = {
bounds: { minX: number; minZ: number; maxX: number; maxZ: number };
panorama_count: number;
server_id: string;
owner_id: string;
owner_id: string | null;
cover_path: string | null;
map_path: string | null;
rejection_reason: string | null;
@@ -66,7 +66,13 @@ export async function catalog() {
.select(
"id,display_name,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
)
.in("id", [...new Set(maps.map((m) => m.owner_id))]),
.in("id", [
...new Set(
maps
.map((m) => m.owner_id)
.filter((id): id is string => id !== null),
),
]),
)
: [];
const signed = await mediaUrls(
@@ -74,7 +80,9 @@ export async function catalog() {
);
return maps.map((m, index) => ({
...m,
credit: owners.find((p) => p.id === m.owner_id)?.display_name ?? "Explorer",
credit:
owners.find((p) => p.id === m.owner_id)?.display_name ??
(m.owner_id ? "Explorer" : "Deleted player"),
creditGlow: owners.find((p) => p.id === m.owner_id)?.name_glow ?? false,
creditAppearance: owners.find((p) => p.id === m.owner_id),
image: signed[index * 2] ?? undefined,
@@ -94,18 +102,22 @@ export async function getMap(id: string) {
if (!result.data) return null;
const map = result.data as unknown as MapListing;
const [owner, signed] = await Promise.all([
admin()
.from("profiles")
.select(
"display_name,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
)
.eq("id", map.owner_id)
.maybeSingle(),
map.owner_id
? admin()
.from("profiles")
.select(
"display_name,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
)
.eq("id", map.owner_id)
.maybeSingle()
: Promise.resolve({ data: null }),
mediaUrls([map.cover_path, map.map_path]),
]);
return {
...map,
credit: owner.data?.display_name ?? "Explorer",
credit:
owner.data?.display_name ??
(map.owner_id ? "Explorer" : "Deleted player"),
creditGlow: owner.data?.name_glow ?? false,
creditAppearance: owner.data ?? undefined,
image: signed[0],

View File

@@ -36,11 +36,13 @@ async function announcementSource(
.maybeSingle();
if (row.error) required(row);
if (!row.data) return null;
const owner = await db
.from("profiles")
.select("display_name")
.eq("id", row.data.owner_id)
.maybeSingle();
const owner = row.data.owner_id
? await db
.from("profiles")
.select("display_name")
.eq("id", row.data.owner_id)
.maybeSingle()
: { data: null, error: null };
if (owner.error) required(owner);
return {
id: row.data.id,

24
lib/staff-page.ts Normal file
View File

@@ -0,0 +1,24 @@
import "server-only";
import { notFound, redirect } from "next/navigation";
import { admin, required, user } from "./supabase";
import { canReviewMaps, isAdministrator } from "@/shared/permissions";
export async function staffPage(adminOnly = false) {
const account = await user();
const destination = adminOnly ? "/admin" : "/moderation";
if (!account) redirect("/log-in?next=" + encodeURIComponent(destination));
const profile = required(
await admin()
.from("profiles")
.select("role,account_status")
.eq("id", account.id)
.single(),
);
if (
profile.account_status !== "active" ||
!canReviewMaps(profile.role) ||
(adminOnly && !isAdministrator(profile.role))
)
notFound();
return profile;
}

View File

@@ -38,7 +38,8 @@ export async function authClient() {
},
);
}
export const user = cache(async function user() {
/** Authentication identity only. Restricted accounts use this solely for appeals/auth. */
export const rawUser = cache(async function rawUser() {
if (
!process.env.NEXT_PUBLIC_SUPABASE_URL ||
!process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
@@ -50,6 +51,27 @@ export const user = cache(async function user() {
} = await client.auth.getUser();
return user;
});
export const accountState = cache(async function accountState(id: string) {
const result = await admin()
.from("profiles")
.select("account_status")
.eq("id", id)
.maybeSingle();
checkServiceError(result.error);
return result.data?.account_status ?? "active";
});
export async function assertAccountActive(id: string) {
const status = await accountState(id);
if (status !== "active") {
const { restrictionMessage } = await import("@/shared/account-controls");
throw new ServiceError(restrictionMessage(status), 403);
}
}
export const user = cache(async function user() {
const account = await rawUser();
if (account) await assertAccountActive(account.id);
return account;
});
export function required<T>(result: {
data: T;
error: { message: string; code?: string } | null;