From 201cfc864e6e1c23a7aefd89946f73965b4aaaa1 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Sat, 26 Sep 2026 07:35:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(access):=20=E6=9C=8D=E5=8A=A1=E5=99=A8?= =?UTF-8?q?=E6=B2=A1=E8=A3=85=20LuckPerms=20=E6=97=B6=E6=9D=83=E9=99=90?= =?UTF-8?q?=E4=B8=8E=E7=94=A8=E6=88=B7=E7=BB=84=E6=93=8D=E4=BD=9C=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=20409=20luckperms=5Fmissing=EF=BC=8C=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E8=AF=B4=E6=98=8E=E5=8E=9F=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/openapi.yaml | 15 ++++- internal/api/handlers_access.go | 30 ++++++++- internal/api/handlers_access_test.go | 48 ++++++++++++++ panel/src/i18n/resources/en-US/errors.json | 1 + panel/src/i18n/resources/zh-CN/errors.json | 1 + panel/src/lib/api.ts | 2 + panel/src/lib/openapi.gen.ts | 6 +- panel/src/pages/ServerLuckPerms.test.tsx | 75 ++++++++++++++++++++++ panel/src/pages/ServerLuckPerms.tsx | 10 ++- 9 files changed, 178 insertions(+), 10 deletions(-) create mode 100644 panel/src/pages/ServerLuckPerms.test.tsx diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 00ededb..d439051 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -2249,7 +2249,10 @@ paths: '404': $ref: '#/components/responses/NotFound' '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: application/json: schema: { $ref: '#/components/schemas/Error' } @@ -2293,7 +2296,10 @@ paths: '404': $ref: '#/components/responses/NotFound' '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: application/json: schema: { $ref: '#/components/schemas/Error' } @@ -2349,7 +2355,10 @@ paths: '404': $ref: '#/components/responses/NotFound' '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: application/json: schema: { $ref: '#/components/schemas/Error' } diff --git a/internal/api/handlers_access.go b/internal/api/handlers_access.go index 9b6ca0d..89ac356 100644 --- a/internal/api/handlers_access.go +++ b/internal/api/handlers_access.go @@ -57,8 +57,32 @@ var ( "invalid world (allowed: letters, digits, _ -)") errInvalidGroup = newError(http.StatusBadRequest, "bad_request", "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 // named server, enforce owner-or-admin, require readiness, and run ONE // 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 } - out, ok := a.issueAccessCommand(w, r, name, cmd) + out, ok := a.issueLuckPermsCommand(w, r, name, cmd) if !ok { return } @@ -393,7 +417,7 @@ func (a *API) handleAccessGroup(w http.ResponseWriter, r *http.Request) { 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 { return } @@ -435,7 +459,7 @@ func (a *API) handleAccessLuckPermsInfo(w http.ResponseWriter, r *http.Request) 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 { return } diff --git a/internal/api/handlers_access_test.go b/internal/api/handlers_access_test.go index 63bc9be..f3099b4 100644 --- a/internal/api/handlers_access_test.go +++ b/internal/api/handlers_access_test.go @@ -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) + } + }) + } + } +} diff --git a/panel/src/i18n/resources/en-US/errors.json b/panel/src/i18n/resources/en-US/errors.json index b222cc8..4415dfd 100644 --- a/panel/src/i18n/resources/en-US/errors.json +++ b/panel/src/i18n/resources/en-US/errors.json @@ -16,6 +16,7 @@ "cooldown": "Wake is cooling down — try again shortly.", "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.", + "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.", "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.", diff --git a/panel/src/i18n/resources/zh-CN/errors.json b/panel/src/i18n/resources/zh-CN/errors.json index bebe2f7..a088f44 100644 --- a/panel/src/i18n/resources/zh-CN/errors.json +++ b/panel/src/i18n/resources/zh-CN/errors.json @@ -16,6 +16,7 @@ "cooldown": "启动冷却中——请稍后再试。", "not_running": "服务器未在运行——请先启动它再管理访问权限。", "console_unavailable": "暂时无法连接服务器控制台,请稍后重试。", + "luckperms_missing": "这台服务器没有装 LuckPerms,权限和用户组的改动不会生效。大厅请让运维重跑安装脚本来补上;其他服务器需要先装 LuckPerms 插件。", "no_backup": "这台服务器暂时没有可回档的备份。", "backup_corrupt": "这份备份回读校验未通过,已无法完整恢复——请选择另一份备份。", "not_stopped": "回档会覆盖世界的实时存储卷,请先把服务器完全停止再回档。", diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index abeaa1a..28e207e 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -1035,6 +1035,8 @@ export function humanizeError(e: unknown): string { return t("not_running"); case "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 // running/starting server 409s not_stopped; no present backup 404s no_backup; // the restore subsystem may be unwired (503 restore_unavailable). diff --git a/panel/src/lib/openapi.gen.ts b/panel/src/lib/openapi.gen.ts index 562c469..b7d0602 100644 --- a/panel/src/lib/openapi.gen.ts +++ b/panel/src/lib/openapi.gen.ts @@ -4189,7 +4189,7 @@ export interface operations { 401: components["responses"]["Unauthorized"]; 403: components["responses"]["Forbidden"]; 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: { headers: { [name: string]: unknown; @@ -4226,7 +4226,7 @@ export interface operations { 401: components["responses"]["Unauthorized"]; 403: components["responses"]["Forbidden"]; 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: { headers: { [name: string]: unknown; @@ -4274,7 +4274,7 @@ export interface operations { 401: components["responses"]["Unauthorized"]; 403: components["responses"]["Forbidden"]; 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: { headers: { [name: string]: unknown; diff --git a/panel/src/pages/ServerLuckPerms.test.tsx b/panel/src/pages/ServerLuckPerms.test.tsx new file mode 100644 index 0000000..3813d21 --- /dev/null +++ b/panel/src/pages/ServerLuckPerms.test.tsx @@ -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: "admin@example.test", role: "admin" }, + isAdmin: true, + isOwner: false, + }), +})); +vi.mock("@/lib/config", () => ({ loadConfig: () => Promise.resolve({}) })); +vi.mock("@/lib/api", async (importOriginal) => { + const actual = await importOriginal(); + 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( + + + } /> + + , + ); +} + +// 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(); + }); +}); diff --git a/panel/src/pages/ServerLuckPerms.tsx b/panel/src/pages/ServerLuckPerms.tsx index 352e082..7d1cc37 100644 --- a/panel/src/pages/ServerLuckPerms.tsx +++ b/panel/src/pages/ServerLuckPerms.tsx @@ -84,7 +84,7 @@ export function ServerLuckPerms() { const [searchQuery, setSearchQuery] = useState(""); // 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), [name, selectedPlayer] ); @@ -427,6 +427,14 @@ export function ServerLuckPerms() { )} + {/* 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 && ( +
+ {humanizeError(lpError)} +
+ )} + {lpSilent && (
{t("luckperms_no_reply")}