feat(panel): player management

This commit is contained in:
Lemon-miaow committed 2026-07-02 03:30:50 +08:00
1 parent 8f41a003b0
commit 15c58d982d
19 files changed
+2282 -7

No files matched your search

+210 -1
View File
@@ -34,12 +34,29 @@ interface MockServer extends ServerInfo {
owner: AccountID | null;
}
interface AccessState {
whitelist: string[];
banned: string[];
// online is the mock's stand-in for the live RCON "list" roster. Kick and ban
// splice a player out of it so the demo roster reflects the action on reload.
online: string[];
}
interface MockState {
accounts: Record<AccountID, MockAccount>;
servers: MockServer[];
images: WhitelistImage[];
// Per-server §access state, keyed by server name. Lazily created (accessFor) so a
// server only gets an entry once its access is touched; "survival" is pre-seeded
// so the whitelist panel demos a populated list out of the box.
access: Record<string, AccessState>;
}
// PLAYER_NAME mirrors the backend's mcNameRe (handlers_access.go) so the mock
// rejects a malformed player exactly as the real API would (400 bad_request),
// keeping the panel's error path exercisable in dev.
const PLAYER_NAME = /^[A-Za-z0-9_]{1,16}$/;
interface RequestContext {
req: IncomingMessage;
res: ServerResponse;
@@ -132,7 +149,7 @@ function initialState(): MockState {
],
servers: [
server("survival", "Survival SMP", "Running", "owner", {
players: 7,
players: 12,
maxPlayers: 20,
autostartPolicy: "public",
}),
@@ -158,6 +175,34 @@ function initialState(): MockState {
}),
...generatedServers(),
],
access: {
// Seeded past a page (PAGE_SIZE=10) and the search threshold (>8) so the
// whitelist's paging + filter are both exercisable in the mock demo.
survival: {
whitelist: [
"mock_player", "test_player", "Notch", "jeb_", "Dinnerbone",
"Grumm", "Steve", "Alex", "Herobrine", "Technoblade",
"Dream", "GeorgeNotFound", "Sapnap", "BadBoyHalo", "Skeppy",
"Tommyinnit", "Tubbo", "Ranboo", "Wilbur_Soot", "Philza",
"Captain_Puffy", "Nihachu", "Fundy", "Quackity", "Karl_Jacobs",
],
// 12 banned names — past the search threshold (>8) and a page (>10) so the ban
// list's filter + paging demo too; kept distinct from the online roster so the
// mock reads like a real server (you don't ban who's currently on).
banned: [
"Griefer_99", "tnt_troll", "hack_client_x", "spam_bot_01", "lava_caster",
"dupe_glitcher", "griefKing", "nukebot", "AFK_farmer", "chat_spammer",
"xray_cheater", "fly_hacker",
],
// 12 online, matching the server's players:12 — past the search threshold (>8)
// and a page (>10) so the roster's filter + paging are both exercisable, with a
// few non-whitelisted names to try kick / ban on.
online: [
"mock_player", "test_player", "Notch", "Steve", "Alex", "jeb_",
"Dinnerbone", "Griefer_88", "rndGuest_7", "xX_Raider_Xx", "creeper_fan", "Herobrine",
],
},
},
};
}
@@ -552,10 +597,174 @@ function handleServerRoute(ctx: SessionContext): boolean {
claimServer(ctx, serverInfo);
return true;
}
if (ctx.parts[4] === "access") {
return handleAccessMock(ctx, serverInfo);
}
return false;
}
function accessFor(state: MockState, name: string): AccessState {
let entry = state.access[name];
if (!entry) {
entry = { whitelist: [], banned: [], online: [] };
state.access[name] = entry;
}
return entry;
}
function whitelistOutput(players: string[]): string {
if (players.length === 0) return "There are no whitelisted players";
return `There are ${players.length} whitelisted player(s): ${players.join(", ")}`;
}
function listOutput(online: string[], max: number): string {
const head = `There are ${online.length} of a max of ${max} players online:`;
return online.length === 0 ? head : `${head} ${online.join(", ")}`;
}
// banlistOutput reproduces vanilla's multiline "banlist" reply: a header line then
// one "<name> was banned by <source>: <reason>" line per ban. The panel's parser
// (parseBanlistOutput) keys on the " was banned by " marker, so this exercises the
// real shape — header + reasons that carry their own colons and spaces — end to end.
function banlistOutput(banned: string[]): string {
if (banned.length === 0) return "There are no bans.";
const head = `There are ${banned.length} ban(s):`;
const lines = banned.map((p) => `${p} was banned by Server: Banned by an operator.`);
return [head, ...lines].join("\n");
}
// handleAccessMock mirrors issueAccessCommand's two gates — owner/admin AND the
// server being Running (RCON) — before dispatching the whitelist/ban routes. The GET
// whitelist read is behind the SAME Running gate as the writes, exactly as the real
// readiness check covers it (409 not_running on a cold server).
function handleAccessMock(ctx: SessionContext, serverInfo: MockServer): boolean {
if (!canManage(ctx.account, serverInfo)) {
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return true;
}
if (serverInfo.phase !== "Running") {
sendError(
ctx.res,
409,
"not_running",
"server is not running; wake it before managing access",
);
return true;
}
const sub = ctx.parts[5];
const access = accessFor(ctx.state, serverInfo.name);
if (is("GET", ctx) && sub === "whitelist") {
sendJSON(ctx.res, 200, {
name: serverInfo.name,
players: [...access.whitelist],
output: whitelistOutput(access.whitelist),
});
return true;
}
if (is("POST", ctx) && sub === "whitelist") {
void handleListMutation(ctx, serverInfo, access, "whitelist");
return true;
}
if (is("GET", ctx) && sub === "players") {
const max = serverInfo.maxPlayers ?? 0;
sendJSON(ctx.res, 200, {
name: serverInfo.name,
online: access.online.length,
max,
players: [...access.online],
output: listOutput(access.online, max),
});
return true;
}
if (is("POST", ctx) && sub === "kick") {
void handleKickMock(ctx, serverInfo, access);
return true;
}
if (is("GET", ctx) && sub === "ban") {
sendJSON(ctx.res, 200, {
name: serverInfo.name,
players: [...access.banned],
output: banlistOutput(access.banned),
});
return true;
}
if (is("POST", ctx) && sub === "ban") {
void handleListMutation(ctx, serverInfo, access, "ban");
return true;
}
return false;
}
// handleKickMock backs POST .../access/kick: charset-validate the player, drop them
// from the online roster (so a reload reflects it), and echo {name, player, output}.
async function handleKickMock(
ctx: SessionContext,
serverInfo: MockServer,
access: AccessState,
): Promise<void> {
const body = await readJSON<{ player?: string }>(ctx.req);
const player = body.player?.trim() ?? "";
if (!PLAYER_NAME.test(player)) {
sendError(ctx.res, 400, "bad_request", "invalid player name");
return;
}
const i = access.online.indexOf(player);
if (i >= 0) access.online.splice(i, 1);
sendJSON(ctx.res, 200, {
name: serverInfo.name,
player,
output: `[mock] kick ${player}`,
});
}
// handleListMutation backs both POST .../access/whitelist (add|remove) and
// POST .../access/ban (ban|pardon): the same structured {action, player} shape with
// a charset-validated player, echoing back {name, action, player, output}.
async function handleListMutation(
ctx: SessionContext,
serverInfo: MockServer,
access: AccessState,
kind: "whitelist" | "ban",
): Promise<void> {
const body = await readJSON<{ action?: string; player?: string }>(ctx.req);
const player = body.player?.trim() ?? "";
if (!PLAYER_NAME.test(player)) {
sendError(ctx.res, 400, "bad_request", "invalid player name");
return;
}
const list = kind === "whitelist" ? access.whitelist : access.banned;
const addAction = kind === "whitelist" ? "add" : "ban";
const removeAction = kind === "whitelist" ? "remove" : "pardon";
if (body.action === addAction) {
if (!list.includes(player)) list.push(player);
// A ban also removes the player from the live server, so drop them from the
// online roster too — the real "ban" kicks them as a side effect.
if (kind === "ban") {
const oi = access.online.indexOf(player);
if (oi >= 0) access.online.splice(oi, 1);
}
} else if (body.action === removeAction) {
const i = list.indexOf(player);
if (i >= 0) list.splice(i, 1);
} else {
sendError(ctx.res, 400, "bad_request", "unknown action");
return;
}
sendJSON(ctx.res, 200, {
name: serverInfo.name,
action: body.action,
player,
output: `[mock] ${body.action} ${player}`,
});
}
async function handleCommandMock(
ctx: SessionContext,
serverInfo: MockServer,