fix(menu): 大厅菜单按玩家标出每台服能否启动及原因,自己的服排前面,状态改成中文
This commit is contained in:
21 files changed
+1081
-136
No files matched your search
@@ -399,6 +399,8 @@ func (a *API) internalAPIRoutes() []apiRoute {
|
||||
// list/status views never carry.
|
||||
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/claim", Callers: proxy, h: a.handleInternalClaim},
|
||||
{Method: "GET", Pattern: "/api/v1/internal/servers/{name}/menu", Callers: proxy, h: a.handleInternalMenuStatus},
|
||||
// What this player may start, for every tile at once: one call per menu open.
|
||||
{Method: "GET", Pattern: "/api/v1/internal/player/menu-access/{mc_uuid}", Callers: proxy, h: a.handleInternalMenuAccess},
|
||||
// Account linking (spec §10): the in-game /link side mints a one-time code for a
|
||||
// verified UUID. Internal-only — the code is born from an online-mode UUID the
|
||||
// web never holds (account_link_codes has no user_id column).
|
||||
|
||||
@@ -343,6 +343,80 @@ func (a *API) handleInternalMenuStatus(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// Menu access verdicts: what a lobby menu click on a server that is not up would
|
||||
// meet for one player (handleInternalMenuAccess).
|
||||
const (
|
||||
menuRetiring = "retiring" // given up or being deleted: nobody starts it
|
||||
menuStartFailed = "start_failed" // automatic restarts spent: waits for its owner
|
||||
menuOwner = "owner" // the player's own server
|
||||
menuWake = "wake" // the policy lets this player start it
|
||||
menuOwnerOnly = "owner_only" // ownerOnly (or unset): only its owner starts it
|
||||
menuAllowlist = "allowlist" // allowlist, and the player is not on it
|
||||
)
|
||||
|
||||
// handleInternalMenuAccess answers the lobby menu's per-player question (spec §12):
|
||||
// for every user server, whether this verified UUID may start it and why not. The
|
||||
// tiles' live state stays in the shared per-server projection (…/menu); this is one
|
||||
// call per menu open, so a lobby full of players still reads each server's status
|
||||
// once. A server that is up is open to every linked player (handleInternalWake), so
|
||||
// the lobby shows Join there whatever the verdict. The verdicts follow the wake's
|
||||
// own gates with the transient refusals (cooldown, the running-server cap) left out,
|
||||
// since a retry gets past those. Retiring comes first: nobody may start such a
|
||||
// server, so it is the reason a stranger is shown too.
|
||||
func (a *API) handleInternalMenuAccess(w http.ResponseWriter, r *http.Request) {
|
||||
mcUUID := r.PathValue("mc_uuid")
|
||||
if mcUUID == "" {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required"))
|
||||
return
|
||||
}
|
||||
infos, err := a.Cluster.ListServers(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
owners, err := a.Repo.ServerOwners(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
standing, err := a.standingByUUID(r.Context(), mcUUID)
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
verdicts := make(map[string]string, len(infos))
|
||||
for i := range infos {
|
||||
info := &infos[i]
|
||||
if naming.ValidateServerName(info.Name) != nil {
|
||||
continue // the login gate and the lobby are not menu tiles
|
||||
}
|
||||
own := owners[info.Name]
|
||||
switch {
|
||||
case own.Retire != nil:
|
||||
verdicts[info.Name] = menuRetiring
|
||||
case info.StartGaveUp && info.DesiredState == string(v1alpha1.DesiredRunning):
|
||||
verdicts[info.Name] = menuStartFailed
|
||||
case standing.userID != "" && standing.userID == own.OwnerID:
|
||||
verdicts[info.Name] = menuOwner
|
||||
default:
|
||||
ok, err := a.policyAdmits(r.Context(), mcUUID, standing, info, own.OwnerID)
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case ok:
|
||||
verdicts[info.Name] = menuWake
|
||||
case info.AutostartPolicy == string(v1alpha1.AutostartAllowlist):
|
||||
verdicts[info.Name] = menuAllowlist
|
||||
default:
|
||||
verdicts[info.Name] = menuOwnerOnly
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"servers": verdicts})
|
||||
}
|
||||
|
||||
// authorizeWakeByUUID is the internal-face counterpart of authorizeWake (spec
|
||||
// §9.4): it applies the autostartPolicy gate for a wake driven by velocity, where
|
||||
// the joining player is known only by their verified online-mode UUID rather than
|
||||
@@ -358,40 +432,66 @@ func (a *API) authorizeWakeByUUID(ctx context.Context, mcUUID string, info *Serv
|
||||
if info.AutostartPolicy == string(v1alpha1.AutostartPublic) {
|
||||
return nil
|
||||
}
|
||||
// Resolve the UUID to its linked user once; staff role or ownership grants
|
||||
// the bypass. A missing link is not an error here — it just means "no
|
||||
// standing", and a link pointing at a vanished user reads the same way.
|
||||
switch userID, err := a.Repo.UserByMCUUID(ctx, mcUUID); {
|
||||
case err == nil:
|
||||
switch u, err := a.Repo.UserByID(ctx, userID); {
|
||||
case err == nil:
|
||||
if staffRole(u.Role) {
|
||||
return nil
|
||||
}
|
||||
case !errors.Is(err, ErrNotFound):
|
||||
return err
|
||||
}
|
||||
if rec != nil && rec.OwnerID != "" && userID == rec.OwnerID {
|
||||
return nil
|
||||
}
|
||||
case errors.Is(err, ErrNotFound):
|
||||
// unlinked UUID → fall through to the policy gate
|
||||
default:
|
||||
s, err := a.standingByUUID(ctx, mcUUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch info.AutostartPolicy {
|
||||
case string(v1alpha1.AutostartAllowlist):
|
||||
ok, err := a.Repo.UUIDInAllowlist(ctx, info.Name, mcUUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
return nil
|
||||
}
|
||||
return errForbidden
|
||||
default: // ownerOnly or unset → only the owner (handled above) may wake
|
||||
owner := ""
|
||||
if rec != nil {
|
||||
owner = rec.OwnerID
|
||||
}
|
||||
ok, err := a.policyAdmits(ctx, mcUUID, s, info, owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return errForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// uuidStanding is what a verified in-game UUID brings to the autostartPolicy gate:
|
||||
// the user it is linked to ("" when unlinked) and whether that user is staff.
|
||||
type uuidStanding struct {
|
||||
userID string
|
||||
staff bool
|
||||
}
|
||||
|
||||
// standingByUUID resolves the UUID to its linked user once. A missing link is not
|
||||
// an error here — it just means "no standing", and a link pointing at a vanished
|
||||
// user reads as the link without the staff role.
|
||||
func (a *API) standingByUUID(ctx context.Context, mcUUID string) (uuidStanding, error) {
|
||||
userID, err := a.Repo.UserByMCUUID(ctx, mcUUID)
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound):
|
||||
return uuidStanding{}, nil
|
||||
case err != nil:
|
||||
return uuidStanding{}, err
|
||||
}
|
||||
switch u, err := a.Repo.UserByID(ctx, userID); {
|
||||
case err == nil:
|
||||
return uuidStanding{userID: userID, staff: staffRole(u.Role)}, nil
|
||||
case errors.Is(err, ErrNotFound):
|
||||
return uuidStanding{userID: userID}, nil
|
||||
default:
|
||||
return uuidStanding{}, err
|
||||
}
|
||||
}
|
||||
|
||||
// policyAdmits is the autostartPolicy gate itself: public admits anyone, staff and
|
||||
// the owner (ownerID, "" while unclaimed) pass every policy, allowlist admits a
|
||||
// listed UUID, and ownerOnly or unset admits no one else.
|
||||
func (a *API) policyAdmits(ctx context.Context, mcUUID string, s uuidStanding, info *ServerInfo, ownerID string) (bool, error) {
|
||||
if info.AutostartPolicy == string(v1alpha1.AutostartPublic) || s.staff {
|
||||
return true, nil
|
||||
}
|
||||
if s.userID != "" && s.userID == ownerID {
|
||||
return true, nil
|
||||
}
|
||||
if info.AutostartPolicy == string(v1alpha1.AutostartAllowlist) {
|
||||
return a.Repo.UUIDInAllowlist(ctx, info.Name, mcUUID)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// writeLookupError maps a repo/cluster lookup error onto an HTTP status: a
|
||||
|
||||
@@ -2,9 +2,13 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||
)
|
||||
|
||||
// The internal-face claim + menu pair (spec §9.3, §12) is what velocity drives for
|
||||
@@ -217,3 +221,117 @@ func (f *fakeRepo) assertClaimAudit(t *testing.T, name string) {
|
||||
}
|
||||
t.Fatalf("no velocity/internal claim audit for %q in %+v", name, f.audits)
|
||||
}
|
||||
|
||||
func internalMenuAccess(api *API, uuid string) *httptest.ResponseRecorder {
|
||||
return do(api.InternalHandler(), "GET", "/api/v1/internal/player/menu-access/"+uuid, "", nil)
|
||||
}
|
||||
|
||||
// The menu-access verdicts tell the lobby, per tile, whether this player may start
|
||||
// the server and why not, with the wake's own gates behind each one.
|
||||
func TestInternalMenuAccess(t *testing.T) {
|
||||
gone := &RetireState{RequestedAt: time.Now()}
|
||||
setup := func() (*API, *fakeRepo) {
|
||||
repo := newFakeRepo()
|
||||
cl := newFakeCluster()
|
||||
running := string(v1alpha1.DesiredRunning)
|
||||
cl.list = []ServerInfo{
|
||||
{Name: "pub", AutostartPolicy: "public"},
|
||||
{Name: "mine", AutostartPolicy: "ownerOnly"},
|
||||
{Name: "theirs", AutostartPolicy: "ownerOnly"},
|
||||
{Name: "unset"},
|
||||
{Name: "listed", AutostartPolicy: "allowlist"},
|
||||
{Name: "unlisted", AutostartPolicy: "allowlist"},
|
||||
{Name: "ownerless", AutostartPolicy: "ownerOnly"},
|
||||
{Name: "gone", AutostartPolicy: "public"},
|
||||
{Name: "mine-gone", AutostartPolicy: "ownerOnly"},
|
||||
{Name: "broken", AutostartPolicy: "public", StartGaveUp: true, DesiredState: running},
|
||||
{Name: "mine-broken", AutostartPolicy: "ownerOnly", StartGaveUp: true, DesiredState: running},
|
||||
{Name: "broken-stopped", AutostartPolicy: "public", StartGaveUp: true},
|
||||
{Name: "lobby", AutostartPolicy: "public"},
|
||||
}
|
||||
repo.owners = map[string]ServerOwnership{
|
||||
"pub": {OwnerID: "u2"},
|
||||
"mine": {OwnerID: "user1"},
|
||||
"theirs": {OwnerID: "u2"},
|
||||
"unset": {OwnerID: "u2"},
|
||||
"listed": {OwnerID: "u2"},
|
||||
"unlisted": {OwnerID: "u2"},
|
||||
"ownerless": {},
|
||||
"gone": {OwnerID: "u2", Retire: gone},
|
||||
"mine-gone": {OwnerID: "user1", Retire: gone},
|
||||
"broken": {OwnerID: "u2"},
|
||||
"mine-broken": {OwnerID: "user1"},
|
||||
"broken-stopped": {OwnerID: "u2"},
|
||||
}
|
||||
repo.allowUUID["listed"] = map[string]bool{menuUUID: true}
|
||||
return newTestAPI(repo, cl), repo
|
||||
}
|
||||
verdicts := func(t *testing.T, api *API) map[string]any {
|
||||
t.Helper()
|
||||
got := decodeMenu(t, internalMenuAccess(api, menuUUID))
|
||||
servers, ok := got["servers"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("servers = %v, want an object", got["servers"])
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
t.Run("a linked player", func(t *testing.T) {
|
||||
api, repo := setup()
|
||||
repo.links[menuUUID] = "user1"
|
||||
got := verdicts(t, api)
|
||||
for name, want := range map[string]string{
|
||||
"pub": "wake",
|
||||
"mine": "owner",
|
||||
"theirs": "owner_only",
|
||||
"unset": "owner_only",
|
||||
"listed": "wake",
|
||||
"unlisted": "allowlist",
|
||||
"ownerless": "owner_only",
|
||||
"gone": "retiring",
|
||||
"mine-gone": "retiring",
|
||||
"broken": "start_failed",
|
||||
"mine-broken": "start_failed",
|
||||
"broken-stopped": "wake",
|
||||
} {
|
||||
assertEq(t, name, got[name], want)
|
||||
}
|
||||
if _, listed := got["lobby"]; listed {
|
||||
t.Fatal("the lobby is not a menu tile, yet it has a verdict")
|
||||
}
|
||||
assertEq(t, "verdict count", len(got), 12)
|
||||
})
|
||||
|
||||
t.Run("staff start any server that is not retiring or failed", func(t *testing.T) {
|
||||
api, repo := setup()
|
||||
repo.links[menuUUID] = "a1"
|
||||
repo.staff["op"] = &StaffUser{ID: "a1", Username: "op", Role: "admin"}
|
||||
got := verdicts(t, api)
|
||||
assertEq(t, "theirs", got["theirs"], "wake")
|
||||
assertEq(t, "unlisted", got["unlisted"], "wake")
|
||||
assertEq(t, "ownerless", got["ownerless"], "wake")
|
||||
assertEq(t, "gone", got["gone"], "retiring")
|
||||
})
|
||||
|
||||
t.Run("an unlinked UUID owns nothing, not even an ownerless server", func(t *testing.T) {
|
||||
api, _ := setup()
|
||||
got := verdicts(t, api)
|
||||
assertEq(t, "mine", got["mine"], "owner_only")
|
||||
assertEq(t, "ownerless", got["ownerless"], "owner_only")
|
||||
assertEq(t, "listed", got["listed"], "wake")
|
||||
assertEq(t, "pub", got["pub"], "wake")
|
||||
})
|
||||
|
||||
t.Run("a failed read fails the call", func(t *testing.T) {
|
||||
api, repo := setup()
|
||||
repo.ownersErr = errors.New("db down")
|
||||
if w := internalMenuAccess(api, menuUUID); w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("owners unreadable: code = %d, want 500 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
api, _ = setup()
|
||||
api.Cluster.(*fakeCluster).listErr = errors.New("apiserver down")
|
||||
if w := internalMenuAccess(api, menuUUID); w.Code < 500 {
|
||||
t.Fatalf("cluster unreadable: code = %d, want 5xx (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user