Unverified Commit 4ae64cc2 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(menu): 大厅菜单按玩家标出每台服能否启动及原因,自己的服排前面,状态改成中文

parent 82310d02
Loading
Loading
Loading
Loading
+37 −1
Changes for docs/openapi.yaml: 37 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -1361,7 +1361,6 @@ paths:
      security: [{ serviceToken: [] }]
      parameters:
        - { name: name, in: path, required: true, schema: { type: string } }
        - { name: mc_uuid, in: query, required: false, schema: { type: string } }
      responses:
        '200':
          description: Menu projection for the lobby UI.
@@ -1382,6 +1381,43 @@ paths:
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/internal/player/menu-access/{mc_uuid}:
    get:
      tags: [lobby]
      operationId: internalMenuAccess
      summary: What one player may start, for every user server — the lobby menu's per-player verdicts.
      description: >
        One call per menu open; each tile's live state stays in the shared
        per-server projection (…/menu). A server that is up is open to every
        linked player, so the lobby shows Join there whatever the verdict. The
        verdicts follow the internal wake's gates without the transient ones
        (cooldown, running-server cap): `retiring` (given up or being deleted),
        `start_failed` (automatic restarts spent), `owner` (the player's own),
        `wake` (the autostart policy admits the player), `owner_only`, and
        `allowlist` (the player is not on the list).
      x-felis-face: [internal]
      x-felis-tier: service
      x-felis-callers: [velocity]
      security: [{ serviceToken: [] }]
      parameters:
        - { name: mc_uuid, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Verdict per user server name.
          content:
            application/json:
              schema:
                type: object
                required: [servers]
                properties:
                  servers:
                    type: object
                    additionalProperties:
                      type: string
                      enum: [retiring, start_failed, owner, wake, owner_only, allowlist]
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v1/internal/account/link/code:
    post:
      tags: [account-internal]
+2 −0
Changes for internal/api/api.go: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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).
+123 −23
Changes for internal/api/handlers_internal.go: 123 added lines, 23 removed lines.
Original line number Diff line number Diff line
@@ -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
	s, err := a.standingByUUID(ctx, mcUUID)
	if err != nil {
		return err
	}
		case !errors.Is(err, ErrNotFound):
	owner := ""
	if rec != nil {
		owner = rec.OwnerID
	}
	ok, err := a.policyAdmits(ctx, mcUUID, s, info, owner)
	if err != nil {
		return err
	}
		if rec != nil && rec.OwnerID != "" && userID == rec.OwnerID {
	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):
		// unlinked UUID → fall through to the policy gate
		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 err
		return uuidStanding{}, 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

// 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
	}
		return errForbidden
	default: // ownerOnly or unset → only the owner (handled above) may wake
		return errForbidden
	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
+118 −0
Changes for internal/api/handlers_internal_menu_test.go: 118 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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())
		}
	})
}
+48 −3
Changes for panel/src/lib/openapi.gen.ts: 48 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -230,6 +230,26 @@ export interface paths {
        patch?: never;
        trace?: never;
    };
    "/api/v1/internal/player/menu-access/{mc_uuid}": {
        parameters: {
            query?: never;
            header?: never;
            path?: never;
            cookie?: never;
        };
        /**
         * What one player may start, for every user server — the lobby menu's per-player verdicts.
         * @description One call per menu open; each tile's live state stays in the shared per-server projection (…/menu). A server that is up is open to every linked player, so the lobby shows Join there whatever the verdict. The verdicts follow the internal wake's gates without the transient ones (cooldown, running-server cap): `retiring` (given up or being deleted), `start_failed` (automatic restarts spent), `owner` (the player's own), `wake` (the autostart policy admits the player), `owner_only`, and `allowlist` (the player is not on the list).
         */
        get: operations["internalMenuAccess"];
        put?: never;
        post?: never;
        delete?: never;
        options?: never;
        head?: never;
        patch?: never;
        trace?: never;
    };
    "/api/v1/internal/account/link/code": {
        parameters: {
            query?: never;
@@ -3347,9 +3367,7 @@ export interface operations {
    };
    internalMenuStatus: {
        parameters: {
            query?: {
                mc_uuid?: string;
            };
            query?: never;
            header?: never;
            path: {
                name: string;
@@ -3380,6 +3398,33 @@ export interface operations {
            404: components["responses"]["NotFound"];
        };
    };
    internalMenuAccess: {
        parameters: {
            query?: never;
            header?: never;
            path: {
                mc_uuid: string;
            };
            cookie?: never;
        };
        requestBody?: never;
        responses: {
            /** @description Verdict per user server name. */
            200: {
                headers: {
                    [name: string]: unknown;
                };
                content: {
                    "application/json": {
                        servers: {
                            [key: string]: "retiring" | "start_failed" | "owner" | "wake" | "owner_only" | "allowlist";
                        };
                    };
                };
            };
            401: components["responses"]["Unauthorized"];
        };
    };
    createLinkCode: {
        parameters: {
            query?: never;
Loading