Add account restrictions, appeals, and admin moderation tools
This commit is contained in:
@@ -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
205
lib/account-controls-api.ts
Normal 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);
|
||||
}
|
||||
32
lib/api.ts
32
lib/api.ts
@@ -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")
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
24
lib/staff-page.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user