feat(access): 服务器没装 LuckPerms 时权限与用户组操作返回 409 luckperms_missing,面板说明原因
This commit is contained in:
9 files changed
+178
-10
No files matched your search
+12
-3
@@ -2249,7 +2249,10 @@ paths:
|
|||||||
'404':
|
'404':
|
||||||
$ref: '#/components/responses/NotFound'
|
$ref: '#/components/responses/NotFound'
|
||||||
'409':
|
'409':
|
||||||
description: Server not running.
|
description: >-
|
||||||
|
not_running (the server is not running) or luckperms_missing (the
|
||||||
|
server answered the lp command as unknown: LuckPerms is not installed,
|
||||||
|
and nothing changed).
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: '#/components/schemas/Error' }
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
@@ -2293,7 +2296,10 @@ paths:
|
|||||||
'404':
|
'404':
|
||||||
$ref: '#/components/responses/NotFound'
|
$ref: '#/components/responses/NotFound'
|
||||||
'409':
|
'409':
|
||||||
description: Server not running.
|
description: >-
|
||||||
|
not_running (the server is not running) or luckperms_missing (the
|
||||||
|
server answered the lp command as unknown: LuckPerms is not installed,
|
||||||
|
and nothing changed).
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: '#/components/schemas/Error' }
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
@@ -2349,7 +2355,10 @@ paths:
|
|||||||
'404':
|
'404':
|
||||||
$ref: '#/components/responses/NotFound'
|
$ref: '#/components/responses/NotFound'
|
||||||
'409':
|
'409':
|
||||||
description: Server not running.
|
description: >-
|
||||||
|
not_running (the server is not running) or luckperms_missing (the
|
||||||
|
server answered the lp command as unknown: LuckPerms is not installed,
|
||||||
|
and nothing changed).
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: '#/components/schemas/Error' }
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
|||||||
@@ -57,8 +57,32 @@ var (
|
|||||||
"invalid world (allowed: letters, digits, _ -)")
|
"invalid world (allowed: letters, digits, _ -)")
|
||||||
errInvalidGroup = newError(http.StatusBadRequest, "bad_request",
|
errInvalidGroup = newError(http.StatusBadRequest, "bad_request",
|
||||||
"invalid group (allowed: letters, digits, _ -)")
|
"invalid group (allowed: letters, digits, _ -)")
|
||||||
|
errLuckPermsMissing = newError(http.StatusConflict, "luckperms_missing",
|
||||||
|
"LuckPerms is not installed on this server, so permission and group changes have no effect; "+
|
||||||
|
"the lobby gets it back by re-running the installer, another server needs the LuckPerms plugin added")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// unknownCommandRe matches the server's reply to a command no plugin registered,
|
||||||
|
// after color stripping. Brigadier servers (Paper, vanilla 1.13+) answer
|
||||||
|
// "Unknown or incomplete command. See below for error" (older builds: ", see
|
||||||
|
// below"), Spigot and legacy Bukkit "Unknown command. Type "/help" for help.".
|
||||||
|
// With LuckPerms installed an lp command never gets this: LuckPerms answers
|
||||||
|
// asynchronously, after RCON has flushed the reply, so the body is empty.
|
||||||
|
var unknownCommandRe = regexp.MustCompile(`(?i)^\s*unknown (or incomplete )?command\b`)
|
||||||
|
|
||||||
|
// issueLuckPermsCommand runs an lp command through issueAccessCommand and turns
|
||||||
|
// the reply of a server without LuckPerms into 409 luckperms_missing (#4). Without
|
||||||
|
// it that reply went back as a success whose output nobody reads, and the change
|
||||||
|
// silently did nothing.
|
||||||
|
func (a *API) issueLuckPermsCommand(w http.ResponseWriter, r *http.Request, name, command string) (string, bool) {
|
||||||
|
out, ok := a.issueAccessCommand(w, r, name, command)
|
||||||
|
if ok && unknownCommandRe.MatchString(lpColorRe.ReplaceAllString(out, "")) {
|
||||||
|
writeError(w, r, errLuckPermsMissing)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return out, ok
|
||||||
|
}
|
||||||
|
|
||||||
// issueAccessCommand is the shared spine of every §7 access mutation: resolve the
|
// issueAccessCommand is the shared spine of every §7 access mutation: resolve the
|
||||||
// named server, enforce owner-or-admin, require readiness, and run ONE
|
// named server, enforce owner-or-admin, require readiness, and run ONE
|
||||||
// already-validated RCON command, returning its reply. It centralises the
|
// already-validated RCON command, returning its reply. It centralises the
|
||||||
@@ -350,7 +374,7 @@ func (a *API) handleAccessPermission(w http.ResponseWriter, r *http.Request) {
|
|||||||
cmd += " world=" + body.World
|
cmd += " world=" + body.World
|
||||||
}
|
}
|
||||||
|
|
||||||
out, ok := a.issueAccessCommand(w, r, name, cmd)
|
out, ok := a.issueLuckPermsCommand(w, r, name, cmd)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -393,7 +417,7 @@ func (a *API) handleAccessGroup(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
out, ok := a.issueAccessCommand(w, r, name, "lp user "+body.Player+" parent "+body.Action+" "+body.Group)
|
out, ok := a.issueLuckPermsCommand(w, r, name, "lp user "+body.Player+" parent "+body.Action+" "+body.Group)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -435,7 +459,7 @@ func (a *API) handleAccessLuckPermsInfo(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
out, ok := a.issueAccessCommand(w, r, name, "lp user "+player+" permission info")
|
out, ok := a.issueLuckPermsCommand(w, r, name, "lp user "+player+" permission info")
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -597,3 +597,51 @@ func TestParseBanlistOutput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLuckPermsMissingIsAConflict: on a server without LuckPerms every lp command
|
||||||
|
// is answered by the server's unknown-command reply, and each LuckPerms door must
|
||||||
|
// say so (409 luckperms_missing) with no audit of a change that never happened (#4).
|
||||||
|
// The replies are what the servers send: Paper 1.21's captured live from a paper
|
||||||
|
// server over RCON, and a Spigot one colored the way its console renders it. The
|
||||||
|
// empty reply LuckPerms itself gives (it answers after RCON has flushed) and an
|
||||||
|
// LP message that mentions "unknown command" mid-line both stay successes.
|
||||||
|
func TestLuckPermsMissingIsAConflict(t *testing.T) {
|
||||||
|
doors := []struct{ name, method, path, body string }{
|
||||||
|
{"permission", "POST", "/api/v1/servers/survival/access/permission", `{"action":"set","player":"Steve","node":"essentials.fly"}`},
|
||||||
|
{"group", "POST", "/api/v1/servers/survival/access/group", `{"action":"add","player":"Steve","group":"vip"}`},
|
||||||
|
{"info", "GET", "/api/v1/servers/survival/access/luckperms/Steve", ""},
|
||||||
|
}
|
||||||
|
replies := []struct {
|
||||||
|
name string
|
||||||
|
reply string
|
||||||
|
missing bool
|
||||||
|
}{
|
||||||
|
{"paper", "Unknown or incomplete command. See below for error\nlp user Steve permission info<--[HERE]", true},
|
||||||
|
{"older vanilla", "Unknown or incomplete command, see below for error\nlp user Steve<--[HERE]", true},
|
||||||
|
{"spigot colored", "§fUnknown command. Type \"/help\" for help.", true},
|
||||||
|
{"luckperms silent", "", false},
|
||||||
|
{"luckperms message", "§7[§b§lL§3§lP§7]§r §7Another command is being executed; unknown command queue", false},
|
||||||
|
}
|
||||||
|
for _, d := range doors {
|
||||||
|
for _, rp := range replies {
|
||||||
|
t.Run(d.name+"/"+rp.name, func(t *testing.T) {
|
||||||
|
api, repo, _, console := mkAccess(t)
|
||||||
|
api.External = staticExternal{p: accessOwner}
|
||||||
|
console.reply = rp.reply
|
||||||
|
w := do(api.ExternalHandler(), d.method, d.path, d.body, nil)
|
||||||
|
if !rp.missing {
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("code = %d (%s), want 200", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if w.Code != http.StatusConflict || decodeErr(t, w) != "luckperms_missing" {
|
||||||
|
t.Fatalf("code = %d (%s), want 409 luckperms_missing", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if len(repo.audits) != 0 {
|
||||||
|
t.Fatalf("audited a change LuckPerms never made: %+v", repo.audits)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"cooldown": "Wake is cooling down — try again shortly.",
|
"cooldown": "Wake is cooling down — try again shortly.",
|
||||||
"not_running": "The server isn't running — wake it before managing access.",
|
"not_running": "The server isn't running — wake it before managing access.",
|
||||||
"console_unavailable": "Can't reach the server console right now — try again shortly.",
|
"console_unavailable": "Can't reach the server console right now — try again shortly.",
|
||||||
|
"luckperms_missing": "LuckPerms isn't installed on this server, so permission and group changes have no effect. For the lobby, ask the operator to re-run the installer; any other server needs the LuckPerms plugin added first.",
|
||||||
"no_backup": "There's no restorable backup for this server yet.",
|
"no_backup": "There's no restorable backup for this server yet.",
|
||||||
"backup_corrupt": "This backup failed its read-back check and can't be restored intact — pick another backup.",
|
"backup_corrupt": "This backup failed its read-back check and can't be restored intact — pick another backup.",
|
||||||
"not_stopped": "Stop the server completely before restoring — a restore overwrites the live world volume.",
|
"not_stopped": "Stop the server completely before restoring — a restore overwrites the live world volume.",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"cooldown": "启动冷却中——请稍后再试。",
|
"cooldown": "启动冷却中——请稍后再试。",
|
||||||
"not_running": "服务器未在运行——请先启动它再管理访问权限。",
|
"not_running": "服务器未在运行——请先启动它再管理访问权限。",
|
||||||
"console_unavailable": "暂时无法连接服务器控制台,请稍后重试。",
|
"console_unavailable": "暂时无法连接服务器控制台,请稍后重试。",
|
||||||
|
"luckperms_missing": "这台服务器没有装 LuckPerms,权限和用户组的改动不会生效。大厅请让运维重跑安装脚本来补上;其他服务器需要先装 LuckPerms 插件。",
|
||||||
"no_backup": "这台服务器暂时没有可回档的备份。",
|
"no_backup": "这台服务器暂时没有可回档的备份。",
|
||||||
"backup_corrupt": "这份备份回读校验未通过,已无法完整恢复——请选择另一份备份。",
|
"backup_corrupt": "这份备份回读校验未通过,已无法完整恢复——请选择另一份备份。",
|
||||||
"not_stopped": "回档会覆盖世界的实时存储卷,请先把服务器完全停止再回档。",
|
"not_stopped": "回档会覆盖世界的实时存储卷,请先把服务器完全停止再回档。",
|
||||||
|
|||||||
@@ -1035,6 +1035,8 @@ export function humanizeError(e: unknown): string {
|
|||||||
return t("not_running");
|
return t("not_running");
|
||||||
case "console_unavailable":
|
case "console_unavailable":
|
||||||
return t("console_unavailable");
|
return t("console_unavailable");
|
||||||
|
case "luckperms_missing":
|
||||||
|
return t("luckperms_missing");
|
||||||
// World restore (spec §7 restore-backup): the world volume must be free, so a
|
// World restore (spec §7 restore-backup): the world volume must be free, so a
|
||||||
// running/starting server 409s not_stopped; no present backup 404s no_backup;
|
// running/starting server 409s not_stopped; no present backup 404s no_backup;
|
||||||
// the restore subsystem may be unwired (503 restore_unavailable).
|
// the restore subsystem may be unwired (503 restore_unavailable).
|
||||||
|
|||||||
@@ -4189,7 +4189,7 @@ export interface operations {
|
|||||||
401: components["responses"]["Unauthorized"];
|
401: components["responses"]["Unauthorized"];
|
||||||
403: components["responses"]["Forbidden"];
|
403: components["responses"]["Forbidden"];
|
||||||
404: components["responses"]["NotFound"];
|
404: components["responses"]["NotFound"];
|
||||||
/** @description Server not running. */
|
/** @description not_running (the server is not running) or luckperms_missing (the server answered the lp command as unknown: LuckPerms is not installed, and nothing changed). */
|
||||||
409: {
|
409: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
@@ -4226,7 +4226,7 @@ export interface operations {
|
|||||||
401: components["responses"]["Unauthorized"];
|
401: components["responses"]["Unauthorized"];
|
||||||
403: components["responses"]["Forbidden"];
|
403: components["responses"]["Forbidden"];
|
||||||
404: components["responses"]["NotFound"];
|
404: components["responses"]["NotFound"];
|
||||||
/** @description Server not running. */
|
/** @description not_running (the server is not running) or luckperms_missing (the server answered the lp command as unknown: LuckPerms is not installed, and nothing changed). */
|
||||||
409: {
|
409: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
@@ -4274,7 +4274,7 @@ export interface operations {
|
|||||||
401: components["responses"]["Unauthorized"];
|
401: components["responses"]["Unauthorized"];
|
||||||
403: components["responses"]["Forbidden"];
|
403: components["responses"]["Forbidden"];
|
||||||
404: components["responses"]["NotFound"];
|
404: components["responses"]["NotFound"];
|
||||||
/** @description Server not running. */
|
/** @description not_running (the server is not running) or luckperms_missing (the server answered the lp command as unknown: LuckPerms is not installed, and nothing changed). */
|
||||||
409: {
|
409: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import i18next from "i18next";
|
||||||
|
import { ServerLuckPerms } from "./ServerLuckPerms";
|
||||||
|
|
||||||
|
const calls = vi.hoisted(() => ({
|
||||||
|
status: vi.fn(),
|
||||||
|
myServers: vi.fn(),
|
||||||
|
accessPlayers: vi.fn(),
|
||||||
|
accessLuckPermsInfo: vi.fn(),
|
||||||
|
accessGroup: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/tier", () => ({
|
||||||
|
useTier: () => ({
|
||||||
|
loading: false,
|
||||||
|
identity: { user_id: "admin-1", email: "[email protected]", role: "admin" },
|
||||||
|
isAdmin: true,
|
||||||
|
isOwner: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/config", () => ({ loadConfig: () => Promise.resolve({}) }));
|
||||||
|
vi.mock("@/lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("@/lib/api")>();
|
||||||
|
return { ...actual, api: { ...actual.api, ...calls } };
|
||||||
|
});
|
||||||
|
|
||||||
|
const missing = { status: 409, code: "luckperms_missing", message: "LuckPerms is not installed on this server" };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const fn of Object.values(calls)) fn.mockReset();
|
||||||
|
calls.status.mockResolvedValue({ name: "lobby", displayName: "Lobby", phase: "Running" });
|
||||||
|
calls.myServers.mockResolvedValue([]);
|
||||||
|
calls.accessPlayers.mockResolvedValue({ online: 0, max: 20, players: [], output: "" });
|
||||||
|
calls.accessLuckPermsInfo.mockRejectedValue(missing);
|
||||||
|
calls.accessGroup.mockRejectedValue(missing);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
return i18next.changeLanguage("en-US");
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={["/servers/lobby/luckperms"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/servers/:name/luckperms" element={<ServerLuckPerms />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A server without LuckPerms (#4): the read of a player says why it came back
|
||||||
|
// empty, and a write is refused with the same cause and never lands in the
|
||||||
|
// history as a success.
|
||||||
|
describe("ServerLuckPerms without LuckPerms", () => {
|
||||||
|
it("says LuckPerms is missing on the read and on a refused write", async () => {
|
||||||
|
renderPage();
|
||||||
|
await userEvent.type(await screen.findByPlaceholderText("Steve"), "Alex{Enter}");
|
||||||
|
|
||||||
|
const alert = await screen.findByRole("alert");
|
||||||
|
expect(alert.textContent).toMatch(/LuckPerms isn't installed/);
|
||||||
|
expect(calls.accessLuckPermsInfo).toHaveBeenCalledWith("lobby", "Alex");
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText("Group Name"), "vip");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /add parent group/i }));
|
||||||
|
expect(calls.accessGroup).toHaveBeenCalledWith("lobby", "add", "Alex", "vip");
|
||||||
|
const alerts = await screen.findAllByRole("alert");
|
||||||
|
expect(alerts).toHaveLength(2);
|
||||||
|
expect(alerts.every((a) => /LuckPerms isn't installed/.test(a.textContent ?? ""))).toBe(true);
|
||||||
|
expect(screen.queryByText(/success/i)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -84,7 +84,7 @@ export function ServerLuckPerms() {
|
|||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
// LuckPerms Profile data for selected player
|
// LuckPerms Profile data for selected player
|
||||||
const { data: lpInfo, loading: lpLoading, reload: reloadLp } = useAsync(
|
const { data: lpInfo, error: lpError, loading: lpLoading, reload: reloadLp } = useAsync(
|
||||||
() => selectedPlayer ? api.accessLuckPermsInfo(name, selectedPlayer) : Promise.resolve(null),
|
() => selectedPlayer ? api.accessLuckPermsInfo(name, selectedPlayer) : Promise.resolve(null),
|
||||||
[name, selectedPlayer]
|
[name, selectedPlayer]
|
||||||
);
|
);
|
||||||
@@ -427,6 +427,14 @@ export function ServerLuckPerms() {
|
|||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* A read that failed (no LuckPerms on the server, console down) says
|
||||||
|
why here; the panels below would otherwise sit empty with no cause. */}
|
||||||
|
{lpError && (
|
||||||
|
<div role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-xs text-destructive">
|
||||||
|
{humanizeError(lpError)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{lpSilent && (
|
{lpSilent && (
|
||||||
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-xs text-muted-foreground">
|
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||||
{t("luckperms_no_reply")}
|
{t("luckperms_no_reply")}
|
||||||
|
|||||||
Reference in new issue
Block a user