Loading docs/openapi.yaml +133 −0 Changes for docs/openapi.yaml: 133 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -1069,7 +1069,88 @@ paths: '503': $ref: '#/components/responses/ServiceUnavailable' /api/v1/servers/{name}/access/players: get: tags: [access] operationId: accessPlayers summary: List online players via RCON (spec §7). Owner/admin only. description: >- Runs "list" against the live server and returns the online/max tally, a best-effort parse of the online player names, and the raw reply. This is the only source of WHO is online — Status.Players carries the count alone. The RCON password is never accepted or returned (spec §286). x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] parameters: - { name: name, in: path, required: true, schema: { type: string } } responses: '200': description: Online players. content: application/json: schema: type: object required: [name, online, max, players, output] properties: name: { type: string } online: { type: integer } max: { type: integer } players: { type: array, items: { type: string } } output: { type: string } '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': description: Server not running. content: application/json: schema: { $ref: '#/components/schemas/Error' } '503': $ref: '#/components/responses/ServiceUnavailable' /api/v1/servers/{name}/access/ban: get: tags: [access] operationId: accessBanList summary: List banned players via RCON (spec §7). Owner/admin only. description: >- Runs "banlist" against the live server and returns a best-effort parse plus the raw reply. The RCON password is never accepted or returned (spec §286). x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] parameters: - { name: name, in: path, required: true, schema: { type: string } } responses: '200': description: Banned players. content: application/json: schema: type: object required: [name, players, output] properties: name: { type: string } players: { type: array, items: { type: string } } output: { type: string } '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': description: Server not running. content: application/json: schema: { $ref: '#/components/schemas/Error' } '503': $ref: '#/components/responses/ServiceUnavailable' post: tags: [access] operationId: accessBan Loading Loading @@ -1112,6 +1193,58 @@ paths: '503': $ref: '#/components/responses/ServiceUnavailable' /api/v1/servers/{name}/access/kick: post: tags: [access] operationId: accessKick summary: Kick a player off the running server (spec §7). Owner/admin only. description: >- Translates to the RCON "kick <player>" command. Unlike ban it does not block rejoining. Carries no reason field (a free-text reason would be an injection vector; the audit log records intent). The RCON password is never accepted or returned (spec §286). x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] parameters: - { name: name, in: path, required: true, schema: { type: string } } requestBody: required: true content: application/json: schema: type: object required: [player] properties: player: { type: string } responses: '200': description: The player was kicked; the raw RCON reply is in output. content: application/json: schema: type: object required: [name, player, output] properties: name: { type: string } player: { type: string } output: { type: string } '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': description: Server not running. content: application/json: schema: { $ref: '#/components/schemas/Error' } '503': $ref: '#/components/responses/ServiceUnavailable' /api/v1/servers/{name}/access/permission: post: tags: [access] Loading internal/api/api.go +3 −0 Changes for internal/api/api.go: 3 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -317,7 +317,10 @@ func (a *API) externalAPIRoutes() []apiRoute { // strict-charset-validated structured fields (handlers_access.go). {Method: "POST", Pattern: "/api/v1/servers/{name}/access/whitelist", h: a.handleAccessWhitelist}, {Method: "GET", Pattern: "/api/v1/servers/{name}/access/whitelist", h: a.handleAccessWhitelistList}, {Method: "GET", Pattern: "/api/v1/servers/{name}/access/players", h: a.handleAccessPlayers}, {Method: "POST", Pattern: "/api/v1/servers/{name}/access/kick", h: a.handleAccessKick}, {Method: "POST", Pattern: "/api/v1/servers/{name}/access/ban", h: a.handleAccessBan}, {Method: "GET", Pattern: "/api/v1/servers/{name}/access/ban", h: a.handleAccessBanList}, {Method: "POST", Pattern: "/api/v1/servers/{name}/access/permission", h: a.handleAccessPermission}, {Method: "POST", Pattern: "/api/v1/servers/{name}/access/group", h: a.handleAccessGroup}, {Method: "GET", Pattern: "/api/v1/servers/{name}/status", h: a.handleStatus}, Loading internal/api/handlers_access.go +130 −0 Changes for internal/api/handlers_access.go: 130 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "regexp" "strconv" "strings" "felis.lolicon.best/internal/naming" Loading Loading @@ -179,6 +180,43 @@ func (a *API) handleAccessWhitelistList(w http.ResponseWriter, r *http.Request) }) } // handleAccessPlayers is the read projector for the online roster: it runs the // vanilla "list" command and returns the online/max tally, a best-effort parse of // the online player names, and the raw reply. Like the whitelist read, the parse // is vanilla-specific (the names arrive after the count line's colon) and the raw // output is always returned so a differing format never loses information. This is // the only place the panel can learn WHO is online — Status.Players carries the // count alone (§141), so this reuses the same RCON reply the prober already sees. func (a *API) handleAccessPlayers(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") out, ok := a.issueAccessCommand(w, r, name, "list") if !ok { return } online, max, players := parseListOutput(out) writeJSON(w, http.StatusOK, map[string]any{ "name": name, "online": online, "max": max, "players": players, "output": out, }) } // handleAccessBanList is the read projector for the ban list: it runs "banlist" // and returns a best-effort parse of the banned names PLUS the raw reply, like the // whitelist / players reads. Unlike them the parse cannot key on a colon tail — a // ban entry reads "<name> was banned by <source>: <reason>" and the reason carries // its own colon — so parseBanlistOutput anchors on the ban marker + name charset // instead. The raw reply is always returned so a plugin or localised format never // loses information. No audit (a read). func (a *API) handleAccessBanList(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") out, ok := a.issueAccessCommand(w, r, name, "banlist") if !ok { return } writeJSON(w, http.StatusOK, map[string]any{ "name": name, "players": parseBanlistOutput(out), "output": out, }) } // banRequest is the body of POST .../access/ban (deny / restore a player's // ability to join, spec §7). It carries NO reason field on purpose: a free-text // reason would be the one place a structured request could splice a second RCON Loading Loading @@ -217,6 +255,39 @@ func (a *API) handleAccessBan(w http.ResponseWriter, r *http.Request) { }) } // kickRequest is the body of POST .../access/kick (remove a player from the // server right now, spec §7). Like ban it carries NO reason field — a free-text // reason is the one place a structured request could splice a second RCON command, // and it buys nothing the audit log does not already record. type kickRequest struct { Player string `json:"player"` } // handleAccessKick kicks a player off the running server via "kick <player>". // Unlike ban it does not block rejoining; it is the immediate "get out now" that // pairs with the online roster. Single-action, so the body carries only a player. func (a *API) handleAccessKick(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") var body kickRequest if err := decodeJSON(w, r, &body); err != nil { writeError(w, r, err) return } if !mcNameRe.MatchString(body.Player) { writeError(w, r, errInvalidPlayer) return } out, ok := a.issueAccessCommand(w, r, name, "kick "+body.Player) if !ok { return } a.audit(r, principalFromContext(r.Context()).Email, "access.kick", name) writeJSON(w, http.StatusOK, map[string]any{ "name": name, "player": body.Player, "output": out, }) } // permissionRequest is the body of POST .../access/permission: a fine-grained // LuckPerms permission grant/deny on a single node, optionally scoped to a world // (spec §7 细致的权限调整 + world 范围). Loading Loading @@ -351,3 +422,62 @@ func parseWhitelistOutput(out string) []string { } return players } // listCountRe matches the count line of vanilla's "list" reply, e.g. // "There are 3 of a max of 20 players online: alice, bob, carol". It mirrors the // operator prober's listReplyPattern; kept local so the api package does not // depend on operator internals for a read it already has the reply for. var listCountRe = regexp.MustCompile(`There are (\d+) of a max of (\d+) players online`) // parseListOutput extracts (online, max, names) from vanilla's "list" reply. The // count comes from the "N of a max of M" line; the names come from the tail after // the colon, comma-separated (each name is [A-Za-z0-9_], so it never contains a // colon or comma of its own). Best-effort and vanilla-specific — the raw reply is // always returned alongside — so a plugin or localised format loses nothing. names // is non-nil so the JSON renders [] not null; online/max are 0 when the count line // does not match (e.g. an empty or unrecognised reply). func parseListOutput(out string) (online, max int, players []string) { players = []string{} if i := strings.Index(out, ":"); i >= 0 { for _, part := range strings.Split(out[i+1:], ",") { if p := strings.TrimSpace(part); p != "" { players = append(players, p) } } } if m := listCountRe.FindStringSubmatch(out); m != nil { online, _ = strconv.Atoi(m[1]) max, _ = strconv.Atoi(m[2]) } return online, max, players } // banEntryRe matches one player-ban entry in vanilla's "banlist" reply, anchored on // the "<name> was banned by" marker with the name pinned to mcNameRe's charset. The // anchoring is deliberate and NOT interchangeable with the whitelist/list tail // parse: "banlist" emits one command-feedback message PER ban, and RCON concatenates // them with a separator that is server/version-dependent (newline, space, or none), // so a line- or colon-split parser could run the "There are N ban(s):" header into // the first entry and emit a non-name. Keying only on the marker + name charset // yields the SAME names under every separator and structurally cannot return a // non-name (group 1 IS the charset), so a corrupt entry can never reach the one-tap // pardon button. It also sidesteps the reason's own colon, which the tail parse can't. // // We issue plain "banlist", which in vanilla lists PLAYER bans only (IP bans are the // separate "banlist ips", which nothing here ever issues), so a "1.2.3.4 was banned // by ..." line — whose trailing octet the charset would otherwise capture as a bogus // short name — never reaches this parser. var banEntryRe = regexp.MustCompile(`([A-Za-z0-9_]{1,16}) was banned by`) // parseBanlistOutput extracts banned player names from vanilla's "banlist" reply, // whose entries read "<name> was banned by <source>: <reason>". Best-effort and // vanilla-specific — the raw reply is always returned alongside — so a plugin or // localised format loses nothing; the "There are no ban(s)." / header lines carry no // marker and are skipped. Returns a non-nil empty slice so the JSON renders [] not null. func parseBanlistOutput(out string) []string { players := []string{} for _, m := range banEntryRe.FindAllStringSubmatch(out, -1) { players = append(players, m[1]) } return players } internal/api/handlers_access_test.go +214 −0 Changes for internal/api/handlers_access_test.go: 214 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -44,6 +44,9 @@ func TestAccessTranslation(t *testing.T) { `{"action":"ban","player":"Griefer_99"}`, "ban Griefer_99", "access.ban.ban"}, {"pardon", "/api/v1/servers/survival/access/ban", `{"action":"pardon","player":"Griefer_99"}`, "pardon Griefer_99", "access.ban.pardon"}, // kick has no action field — a single verb — so its label is "access.kick". {"kick", "/api/v1/servers/survival/access/kick", `{"player":"Griefer_99"}`, "kick Griefer_99", "access.kick"}, // The *bool trap: omitted value defaults to true (grant), NOT false (deny). {"permission set default grant", "/api/v1/servers/survival/access/permission", `{"action":"set","player":"Steve","node":"essentials.fly"}`, Loading Loading @@ -100,6 +103,8 @@ func TestAccessInjectionRejected(t *testing.T) { {"whitelist player newline", "/api/v1/servers/survival/access/whitelist", `{"action":"add","player":"ev\nop x"}`}, {"ban player semicolon", "/api/v1/servers/survival/access/ban", `{"action":"ban","player":"ev;il"}`}, {"ban player space", "/api/v1/servers/survival/access/ban", `{"action":"ban","player":"ev il"}`}, {"kick player space", "/api/v1/servers/survival/access/kick", `{"player":"ev il"}`}, {"kick player newline", "/api/v1/servers/survival/access/kick", `{"player":"ev\nop x"}`}, {"permission player space", "/api/v1/servers/survival/access/permission", `{"action":"set","player":"ev il","node":"essentials.fly"}`}, {"group player newline", "/api/v1/servers/survival/access/group", Loading Loading @@ -383,3 +388,212 @@ func TestParseWhitelistOutput(t *testing.T) { } } } // TestAccessPlayers exercises the online-roster read projector: GET runs "list" // and returns the online/max tally, the parsed names, and the raw reply, owner- // gated like the writes and never auditing. func TestAccessPlayers(t *testing.T) { t.Run("parses tally, names and raw output", func(t *testing.T) { api, repo, _, console := mkAccess(t) console.reply = "There are 3 of a max of 20 players online: alice, bob, carol" api.External = staticExternal{p: accessOwner} w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/players", "", nil) if w.Code != http.StatusOK { t.Fatalf("code = %d body %s", w.Code, w.Body.String()) } var resp struct { Name string `json:"name"` Online int `json:"online"` Max int `json:"max"` Players []string `json:"players"` Output string `json:"output"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("body not JSON: %v (%s)", err, w.Body.String()) } if resp.Name != "survival" || resp.Online != 3 || resp.Max != 20 || resp.Output != console.reply { t.Fatalf("unexpected response %+v", resp) } if len(resp.Players) != 3 || resp.Players[0] != "alice" || resp.Players[2] != "carol" { t.Fatalf("players = %#v, want [alice bob carol]", resp.Players) } if console.gotCommand != "list" { t.Fatalf("console got %q, want %q", console.gotCommand, "list") } if len(repo.audits) != 0 { t.Fatalf("GET players must not audit: %+v", repo.audits) } }) t.Run("empty server -> [] not null", func(t *testing.T) { api, _, _, console := mkAccess(t) console.reply = "There are 0 of a max of 20 players online:" api.External = staticExternal{p: accessOwner} w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/players", "", nil) if w.Code != http.StatusOK { t.Fatalf("code = %d body %s", w.Code, w.Body.String()) } var raw map[string]json.RawMessage if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil { t.Fatalf("body not JSON: %v", err) } if string(raw["players"]) != "[]" { t.Fatalf("players = %s, want []", raw["players"]) } }) t.Run("non-owner -> 403, no RCON call", func(t *testing.T) { api, _, _, console := mkAccess(t) api.External = staticExternal{p: &Principal{UserID: "stranger", Role: "user"}} w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/players", "", nil) if w.Code != http.StatusForbidden { t.Fatalf("code = %d, want 403", w.Code) } if console.calls != 0 { t.Fatal("a forbidden caller must not reach RCON") } }) } // TestParseListOutput unit-tests the "list" parser directly, including formats // issueAccessCommand never produces but a real server might. Names parse from the // colon tail independently of the count line, so both are pinned separately. func TestParseListOutput(t *testing.T) { cases := []struct { in string online, max int want []string }{ {"There are 3 of a max of 20 players online: alice, bob, carol", 3, 20, []string{"alice", "bob", "carol"}}, {"There are 1 of a max of 20 players online: Steve", 1, 20, []string{"Steve"}}, {"There are 0 of a max of 20 players online:", 0, 20, []string{}}, {"There are 0 of a max of 20 players online", 0, 20, []string{}}, {"", 0, 0, []string{}}, // A colon tail with no recognised count line still yields names, tally 0. {"Online: a, b ,c", 0, 0, []string{"a", "b", "c"}}, } for _, tc := range cases { online, max, got := parseListOutput(tc.in) if got == nil { t.Fatalf("parseListOutput(%q) names = nil, want non-nil slice", tc.in) } if online != tc.online || max != tc.max { t.Fatalf("parseListOutput(%q) = (%d,%d), want (%d,%d)", tc.in, online, max, tc.online, tc.max) } if len(got) != len(tc.want) { t.Fatalf("parseListOutput(%q) names = %#v, want %#v", tc.in, got, tc.want) } for i := range got { if got[i] != tc.want[i] { t.Fatalf("parseListOutput(%q)[%d] = %q, want %q", tc.in, i, got[i], tc.want[i]) } } } } // TestAccessBanList exercises the ban-list read projector: GET runs "banlist", // returns the parsed names plus the raw reply, is owner-gated, and never audits. func TestAccessBanList(t *testing.T) { t.Run("parses players and returns raw output", func(t *testing.T) { api, repo, _, console := mkAccess(t) console.reply = "There are 2 ban(s):\nSteve was banned by Server: Griefing\nAlex was banned by Server: Spam" api.External = staticExternal{p: accessOwner} w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/ban", "", nil) if w.Code != http.StatusOK { t.Fatalf("code = %d body %s", w.Code, w.Body.String()) } var resp struct { Name string `json:"name"` Players []string `json:"players"` Output string `json:"output"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("body not JSON: %v (%s)", err, w.Body.String()) } if resp.Name != "survival" || resp.Output != console.reply { t.Fatalf("unexpected response %+v", resp) } if len(resp.Players) != 2 || resp.Players[0] != "Steve" || resp.Players[1] != "Alex" { t.Fatalf("players = %#v, want [Steve Alex]", resp.Players) } if console.gotCommand != "banlist" { t.Fatalf("console got %q, want %q", console.gotCommand, "banlist") } if len(repo.audits) != 0 { t.Fatalf("GET ban must not audit: %+v", repo.audits) } }) t.Run("empty ban list -> [] not null", func(t *testing.T) { api, _, _, console := mkAccess(t) console.reply = "There are no bans." api.External = staticExternal{p: accessOwner} w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/ban", "", nil) if w.Code != http.StatusOK { t.Fatalf("code = %d body %s", w.Code, w.Body.String()) } var raw map[string]json.RawMessage if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil { t.Fatalf("body not JSON: %v", err) } if string(raw["players"]) != "[]" { t.Fatalf("players = %s, want []", raw["players"]) } }) t.Run("non-owner -> 403, no RCON call", func(t *testing.T) { api, _, _, console := mkAccess(t) api.External = staticExternal{p: &Principal{UserID: "stranger", Role: "user"}} w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/ban", "", nil) if w.Code != http.StatusForbidden { t.Fatalf("code = %d, want 403", w.Code) } if console.calls != 0 { t.Fatal("a forbidden caller must not reach RCON") } }) } // TestParseBanlistOutput unit-tests the ban parser directly. The critical cases are // the SEPARATOR variants: "banlist" emits one feedback message per ban and RCON's // concatenation separator is version-dependent, so the parser must return the same // names whether entries are newline-, space-, or non-separated — and must never let // the header or a reason's own colon/spaces leak into a name (a corrupt name would // arm an off-charset pardon). func TestParseBanlistOutput(t *testing.T) { cases := []struct { name string in string want []string }{ { "newline-separated", "There are 2 ban(s):\nSteve was banned by Server: Griefing\nAlex was banned by Server: Spam", []string{"Steve", "Alex"}, }, { // The separator the wire format might actually use: header + entries // concatenated with spaces, reasons carrying spaces of their own. "space-concatenated with spaced reasons", "There are 2 ban(s): Steve was banned by Server: griefing spawn Alex was banned by Server: spam", []string{"Steve", "Alex"}, }, {"single ban", "There are 1 ban(s):\nNotch was banned by Console: rude", []string{"Notch"}}, {"no bans", "There are no bans.", []string{}}, {"empty", "", []string{}}, } for _, tc := range cases { got := parseBanlistOutput(tc.in) if got == nil { t.Fatalf("%s: parseBanlistOutput(%q) = nil, want non-nil slice", tc.name, tc.in) } if len(got) != len(tc.want) { t.Fatalf("%s: parseBanlistOutput(%q) = %#v, want %#v", tc.name, tc.in, got, tc.want) } for i := range got { if got[i] != tc.want[i] { t.Fatalf("%s: parseBanlistOutput(%q)[%d] = %q, want %q", tc.name, tc.in, i, got[i], tc.want[i]) } } } } panel/dev/mockApi.ts +210 −1 File changed.Preview size limit exceeded, changes collapsed. Show changes Loading
docs/openapi.yaml +133 −0 Changes for docs/openapi.yaml: 133 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -1069,7 +1069,88 @@ paths: '503': $ref: '#/components/responses/ServiceUnavailable' /api/v1/servers/{name}/access/players: get: tags: [access] operationId: accessPlayers summary: List online players via RCON (spec §7). Owner/admin only. description: >- Runs "list" against the live server and returns the online/max tally, a best-effort parse of the online player names, and the raw reply. This is the only source of WHO is online — Status.Players carries the count alone. The RCON password is never accepted or returned (spec §286). x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] parameters: - { name: name, in: path, required: true, schema: { type: string } } responses: '200': description: Online players. content: application/json: schema: type: object required: [name, online, max, players, output] properties: name: { type: string } online: { type: integer } max: { type: integer } players: { type: array, items: { type: string } } output: { type: string } '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': description: Server not running. content: application/json: schema: { $ref: '#/components/schemas/Error' } '503': $ref: '#/components/responses/ServiceUnavailable' /api/v1/servers/{name}/access/ban: get: tags: [access] operationId: accessBanList summary: List banned players via RCON (spec §7). Owner/admin only. description: >- Runs "banlist" against the live server and returns a best-effort parse plus the raw reply. The RCON password is never accepted or returned (spec §286). x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] parameters: - { name: name, in: path, required: true, schema: { type: string } } responses: '200': description: Banned players. content: application/json: schema: type: object required: [name, players, output] properties: name: { type: string } players: { type: array, items: { type: string } } output: { type: string } '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': description: Server not running. content: application/json: schema: { $ref: '#/components/schemas/Error' } '503': $ref: '#/components/responses/ServiceUnavailable' post: tags: [access] operationId: accessBan Loading Loading @@ -1112,6 +1193,58 @@ paths: '503': $ref: '#/components/responses/ServiceUnavailable' /api/v1/servers/{name}/access/kick: post: tags: [access] operationId: accessKick summary: Kick a player off the running server (spec §7). Owner/admin only. description: >- Translates to the RCON "kick <player>" command. Unlike ban it does not block rejoining. Carries no reason field (a free-text reason would be an injection vector; the audit log records intent). The RCON password is never accepted or returned (spec §286). x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] parameters: - { name: name, in: path, required: true, schema: { type: string } } requestBody: required: true content: application/json: schema: type: object required: [player] properties: player: { type: string } responses: '200': description: The player was kicked; the raw RCON reply is in output. content: application/json: schema: type: object required: [name, player, output] properties: name: { type: string } player: { type: string } output: { type: string } '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': description: Server not running. content: application/json: schema: { $ref: '#/components/schemas/Error' } '503': $ref: '#/components/responses/ServiceUnavailable' /api/v1/servers/{name}/access/permission: post: tags: [access] Loading
internal/api/api.go +3 −0 Changes for internal/api/api.go: 3 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -317,7 +317,10 @@ func (a *API) externalAPIRoutes() []apiRoute { // strict-charset-validated structured fields (handlers_access.go). {Method: "POST", Pattern: "/api/v1/servers/{name}/access/whitelist", h: a.handleAccessWhitelist}, {Method: "GET", Pattern: "/api/v1/servers/{name}/access/whitelist", h: a.handleAccessWhitelistList}, {Method: "GET", Pattern: "/api/v1/servers/{name}/access/players", h: a.handleAccessPlayers}, {Method: "POST", Pattern: "/api/v1/servers/{name}/access/kick", h: a.handleAccessKick}, {Method: "POST", Pattern: "/api/v1/servers/{name}/access/ban", h: a.handleAccessBan}, {Method: "GET", Pattern: "/api/v1/servers/{name}/access/ban", h: a.handleAccessBanList}, {Method: "POST", Pattern: "/api/v1/servers/{name}/access/permission", h: a.handleAccessPermission}, {Method: "POST", Pattern: "/api/v1/servers/{name}/access/group", h: a.handleAccessGroup}, {Method: "GET", Pattern: "/api/v1/servers/{name}/status", h: a.handleStatus}, Loading
internal/api/handlers_access.go +130 −0 Changes for internal/api/handlers_access.go: 130 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "regexp" "strconv" "strings" "felis.lolicon.best/internal/naming" Loading Loading @@ -179,6 +180,43 @@ func (a *API) handleAccessWhitelistList(w http.ResponseWriter, r *http.Request) }) } // handleAccessPlayers is the read projector for the online roster: it runs the // vanilla "list" command and returns the online/max tally, a best-effort parse of // the online player names, and the raw reply. Like the whitelist read, the parse // is vanilla-specific (the names arrive after the count line's colon) and the raw // output is always returned so a differing format never loses information. This is // the only place the panel can learn WHO is online — Status.Players carries the // count alone (§141), so this reuses the same RCON reply the prober already sees. func (a *API) handleAccessPlayers(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") out, ok := a.issueAccessCommand(w, r, name, "list") if !ok { return } online, max, players := parseListOutput(out) writeJSON(w, http.StatusOK, map[string]any{ "name": name, "online": online, "max": max, "players": players, "output": out, }) } // handleAccessBanList is the read projector for the ban list: it runs "banlist" // and returns a best-effort parse of the banned names PLUS the raw reply, like the // whitelist / players reads. Unlike them the parse cannot key on a colon tail — a // ban entry reads "<name> was banned by <source>: <reason>" and the reason carries // its own colon — so parseBanlistOutput anchors on the ban marker + name charset // instead. The raw reply is always returned so a plugin or localised format never // loses information. No audit (a read). func (a *API) handleAccessBanList(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") out, ok := a.issueAccessCommand(w, r, name, "banlist") if !ok { return } writeJSON(w, http.StatusOK, map[string]any{ "name": name, "players": parseBanlistOutput(out), "output": out, }) } // banRequest is the body of POST .../access/ban (deny / restore a player's // ability to join, spec §7). It carries NO reason field on purpose: a free-text // reason would be the one place a structured request could splice a second RCON Loading Loading @@ -217,6 +255,39 @@ func (a *API) handleAccessBan(w http.ResponseWriter, r *http.Request) { }) } // kickRequest is the body of POST .../access/kick (remove a player from the // server right now, spec §7). Like ban it carries NO reason field — a free-text // reason is the one place a structured request could splice a second RCON command, // and it buys nothing the audit log does not already record. type kickRequest struct { Player string `json:"player"` } // handleAccessKick kicks a player off the running server via "kick <player>". // Unlike ban it does not block rejoining; it is the immediate "get out now" that // pairs with the online roster. Single-action, so the body carries only a player. func (a *API) handleAccessKick(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") var body kickRequest if err := decodeJSON(w, r, &body); err != nil { writeError(w, r, err) return } if !mcNameRe.MatchString(body.Player) { writeError(w, r, errInvalidPlayer) return } out, ok := a.issueAccessCommand(w, r, name, "kick "+body.Player) if !ok { return } a.audit(r, principalFromContext(r.Context()).Email, "access.kick", name) writeJSON(w, http.StatusOK, map[string]any{ "name": name, "player": body.Player, "output": out, }) } // permissionRequest is the body of POST .../access/permission: a fine-grained // LuckPerms permission grant/deny on a single node, optionally scoped to a world // (spec §7 细致的权限调整 + world 范围). Loading Loading @@ -351,3 +422,62 @@ func parseWhitelistOutput(out string) []string { } return players } // listCountRe matches the count line of vanilla's "list" reply, e.g. // "There are 3 of a max of 20 players online: alice, bob, carol". It mirrors the // operator prober's listReplyPattern; kept local so the api package does not // depend on operator internals for a read it already has the reply for. var listCountRe = regexp.MustCompile(`There are (\d+) of a max of (\d+) players online`) // parseListOutput extracts (online, max, names) from vanilla's "list" reply. The // count comes from the "N of a max of M" line; the names come from the tail after // the colon, comma-separated (each name is [A-Za-z0-9_], so it never contains a // colon or comma of its own). Best-effort and vanilla-specific — the raw reply is // always returned alongside — so a plugin or localised format loses nothing. names // is non-nil so the JSON renders [] not null; online/max are 0 when the count line // does not match (e.g. an empty or unrecognised reply). func parseListOutput(out string) (online, max int, players []string) { players = []string{} if i := strings.Index(out, ":"); i >= 0 { for _, part := range strings.Split(out[i+1:], ",") { if p := strings.TrimSpace(part); p != "" { players = append(players, p) } } } if m := listCountRe.FindStringSubmatch(out); m != nil { online, _ = strconv.Atoi(m[1]) max, _ = strconv.Atoi(m[2]) } return online, max, players } // banEntryRe matches one player-ban entry in vanilla's "banlist" reply, anchored on // the "<name> was banned by" marker with the name pinned to mcNameRe's charset. The // anchoring is deliberate and NOT interchangeable with the whitelist/list tail // parse: "banlist" emits one command-feedback message PER ban, and RCON concatenates // them with a separator that is server/version-dependent (newline, space, or none), // so a line- or colon-split parser could run the "There are N ban(s):" header into // the first entry and emit a non-name. Keying only on the marker + name charset // yields the SAME names under every separator and structurally cannot return a // non-name (group 1 IS the charset), so a corrupt entry can never reach the one-tap // pardon button. It also sidesteps the reason's own colon, which the tail parse can't. // // We issue plain "banlist", which in vanilla lists PLAYER bans only (IP bans are the // separate "banlist ips", which nothing here ever issues), so a "1.2.3.4 was banned // by ..." line — whose trailing octet the charset would otherwise capture as a bogus // short name — never reaches this parser. var banEntryRe = regexp.MustCompile(`([A-Za-z0-9_]{1,16}) was banned by`) // parseBanlistOutput extracts banned player names from vanilla's "banlist" reply, // whose entries read "<name> was banned by <source>: <reason>". Best-effort and // vanilla-specific — the raw reply is always returned alongside — so a plugin or // localised format loses nothing; the "There are no ban(s)." / header lines carry no // marker and are skipped. Returns a non-nil empty slice so the JSON renders [] not null. func parseBanlistOutput(out string) []string { players := []string{} for _, m := range banEntryRe.FindAllStringSubmatch(out, -1) { players = append(players, m[1]) } return players }
internal/api/handlers_access_test.go +214 −0 Changes for internal/api/handlers_access_test.go: 214 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -44,6 +44,9 @@ func TestAccessTranslation(t *testing.T) { `{"action":"ban","player":"Griefer_99"}`, "ban Griefer_99", "access.ban.ban"}, {"pardon", "/api/v1/servers/survival/access/ban", `{"action":"pardon","player":"Griefer_99"}`, "pardon Griefer_99", "access.ban.pardon"}, // kick has no action field — a single verb — so its label is "access.kick". {"kick", "/api/v1/servers/survival/access/kick", `{"player":"Griefer_99"}`, "kick Griefer_99", "access.kick"}, // The *bool trap: omitted value defaults to true (grant), NOT false (deny). {"permission set default grant", "/api/v1/servers/survival/access/permission", `{"action":"set","player":"Steve","node":"essentials.fly"}`, Loading Loading @@ -100,6 +103,8 @@ func TestAccessInjectionRejected(t *testing.T) { {"whitelist player newline", "/api/v1/servers/survival/access/whitelist", `{"action":"add","player":"ev\nop x"}`}, {"ban player semicolon", "/api/v1/servers/survival/access/ban", `{"action":"ban","player":"ev;il"}`}, {"ban player space", "/api/v1/servers/survival/access/ban", `{"action":"ban","player":"ev il"}`}, {"kick player space", "/api/v1/servers/survival/access/kick", `{"player":"ev il"}`}, {"kick player newline", "/api/v1/servers/survival/access/kick", `{"player":"ev\nop x"}`}, {"permission player space", "/api/v1/servers/survival/access/permission", `{"action":"set","player":"ev il","node":"essentials.fly"}`}, {"group player newline", "/api/v1/servers/survival/access/group", Loading Loading @@ -383,3 +388,212 @@ func TestParseWhitelistOutput(t *testing.T) { } } } // TestAccessPlayers exercises the online-roster read projector: GET runs "list" // and returns the online/max tally, the parsed names, and the raw reply, owner- // gated like the writes and never auditing. func TestAccessPlayers(t *testing.T) { t.Run("parses tally, names and raw output", func(t *testing.T) { api, repo, _, console := mkAccess(t) console.reply = "There are 3 of a max of 20 players online: alice, bob, carol" api.External = staticExternal{p: accessOwner} w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/players", "", nil) if w.Code != http.StatusOK { t.Fatalf("code = %d body %s", w.Code, w.Body.String()) } var resp struct { Name string `json:"name"` Online int `json:"online"` Max int `json:"max"` Players []string `json:"players"` Output string `json:"output"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("body not JSON: %v (%s)", err, w.Body.String()) } if resp.Name != "survival" || resp.Online != 3 || resp.Max != 20 || resp.Output != console.reply { t.Fatalf("unexpected response %+v", resp) } if len(resp.Players) != 3 || resp.Players[0] != "alice" || resp.Players[2] != "carol" { t.Fatalf("players = %#v, want [alice bob carol]", resp.Players) } if console.gotCommand != "list" { t.Fatalf("console got %q, want %q", console.gotCommand, "list") } if len(repo.audits) != 0 { t.Fatalf("GET players must not audit: %+v", repo.audits) } }) t.Run("empty server -> [] not null", func(t *testing.T) { api, _, _, console := mkAccess(t) console.reply = "There are 0 of a max of 20 players online:" api.External = staticExternal{p: accessOwner} w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/players", "", nil) if w.Code != http.StatusOK { t.Fatalf("code = %d body %s", w.Code, w.Body.String()) } var raw map[string]json.RawMessage if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil { t.Fatalf("body not JSON: %v", err) } if string(raw["players"]) != "[]" { t.Fatalf("players = %s, want []", raw["players"]) } }) t.Run("non-owner -> 403, no RCON call", func(t *testing.T) { api, _, _, console := mkAccess(t) api.External = staticExternal{p: &Principal{UserID: "stranger", Role: "user"}} w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/players", "", nil) if w.Code != http.StatusForbidden { t.Fatalf("code = %d, want 403", w.Code) } if console.calls != 0 { t.Fatal("a forbidden caller must not reach RCON") } }) } // TestParseListOutput unit-tests the "list" parser directly, including formats // issueAccessCommand never produces but a real server might. Names parse from the // colon tail independently of the count line, so both are pinned separately. func TestParseListOutput(t *testing.T) { cases := []struct { in string online, max int want []string }{ {"There are 3 of a max of 20 players online: alice, bob, carol", 3, 20, []string{"alice", "bob", "carol"}}, {"There are 1 of a max of 20 players online: Steve", 1, 20, []string{"Steve"}}, {"There are 0 of a max of 20 players online:", 0, 20, []string{}}, {"There are 0 of a max of 20 players online", 0, 20, []string{}}, {"", 0, 0, []string{}}, // A colon tail with no recognised count line still yields names, tally 0. {"Online: a, b ,c", 0, 0, []string{"a", "b", "c"}}, } for _, tc := range cases { online, max, got := parseListOutput(tc.in) if got == nil { t.Fatalf("parseListOutput(%q) names = nil, want non-nil slice", tc.in) } if online != tc.online || max != tc.max { t.Fatalf("parseListOutput(%q) = (%d,%d), want (%d,%d)", tc.in, online, max, tc.online, tc.max) } if len(got) != len(tc.want) { t.Fatalf("parseListOutput(%q) names = %#v, want %#v", tc.in, got, tc.want) } for i := range got { if got[i] != tc.want[i] { t.Fatalf("parseListOutput(%q)[%d] = %q, want %q", tc.in, i, got[i], tc.want[i]) } } } } // TestAccessBanList exercises the ban-list read projector: GET runs "banlist", // returns the parsed names plus the raw reply, is owner-gated, and never audits. func TestAccessBanList(t *testing.T) { t.Run("parses players and returns raw output", func(t *testing.T) { api, repo, _, console := mkAccess(t) console.reply = "There are 2 ban(s):\nSteve was banned by Server: Griefing\nAlex was banned by Server: Spam" api.External = staticExternal{p: accessOwner} w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/ban", "", nil) if w.Code != http.StatusOK { t.Fatalf("code = %d body %s", w.Code, w.Body.String()) } var resp struct { Name string `json:"name"` Players []string `json:"players"` Output string `json:"output"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("body not JSON: %v (%s)", err, w.Body.String()) } if resp.Name != "survival" || resp.Output != console.reply { t.Fatalf("unexpected response %+v", resp) } if len(resp.Players) != 2 || resp.Players[0] != "Steve" || resp.Players[1] != "Alex" { t.Fatalf("players = %#v, want [Steve Alex]", resp.Players) } if console.gotCommand != "banlist" { t.Fatalf("console got %q, want %q", console.gotCommand, "banlist") } if len(repo.audits) != 0 { t.Fatalf("GET ban must not audit: %+v", repo.audits) } }) t.Run("empty ban list -> [] not null", func(t *testing.T) { api, _, _, console := mkAccess(t) console.reply = "There are no bans." api.External = staticExternal{p: accessOwner} w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/ban", "", nil) if w.Code != http.StatusOK { t.Fatalf("code = %d body %s", w.Code, w.Body.String()) } var raw map[string]json.RawMessage if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil { t.Fatalf("body not JSON: %v", err) } if string(raw["players"]) != "[]" { t.Fatalf("players = %s, want []", raw["players"]) } }) t.Run("non-owner -> 403, no RCON call", func(t *testing.T) { api, _, _, console := mkAccess(t) api.External = staticExternal{p: &Principal{UserID: "stranger", Role: "user"}} w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/access/ban", "", nil) if w.Code != http.StatusForbidden { t.Fatalf("code = %d, want 403", w.Code) } if console.calls != 0 { t.Fatal("a forbidden caller must not reach RCON") } }) } // TestParseBanlistOutput unit-tests the ban parser directly. The critical cases are // the SEPARATOR variants: "banlist" emits one feedback message per ban and RCON's // concatenation separator is version-dependent, so the parser must return the same // names whether entries are newline-, space-, or non-separated — and must never let // the header or a reason's own colon/spaces leak into a name (a corrupt name would // arm an off-charset pardon). func TestParseBanlistOutput(t *testing.T) { cases := []struct { name string in string want []string }{ { "newline-separated", "There are 2 ban(s):\nSteve was banned by Server: Griefing\nAlex was banned by Server: Spam", []string{"Steve", "Alex"}, }, { // The separator the wire format might actually use: header + entries // concatenated with spaces, reasons carrying spaces of their own. "space-concatenated with spaced reasons", "There are 2 ban(s): Steve was banned by Server: griefing spawn Alex was banned by Server: spam", []string{"Steve", "Alex"}, }, {"single ban", "There are 1 ban(s):\nNotch was banned by Console: rude", []string{"Notch"}}, {"no bans", "There are no bans.", []string{}}, {"empty", "", []string{}}, } for _, tc := range cases { got := parseBanlistOutput(tc.in) if got == nil { t.Fatalf("%s: parseBanlistOutput(%q) = nil, want non-nil slice", tc.name, tc.in) } if len(got) != len(tc.want) { t.Fatalf("%s: parseBanlistOutput(%q) = %#v, want %#v", tc.name, tc.in, got, tc.want) } for i := range got { if got[i] != tc.want[i] { t.Fatalf("%s: parseBanlistOutput(%q)[%d] = %q, want %q", tc.name, tc.in, i, got[i], tc.want[i]) } } } }
panel/dev/mockApi.ts +210 −1 File changed.Preview size limit exceeded, changes collapsed. Show changes