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

fix(panel): 服务器列表的归属与可认领改由后端按账号判定,无邮箱管理员也能认出自己的服务器,所有者查询失败时显示未知且不给认领

parent 06d5e652
Loading
Loading
Loading
Loading
+16 −0
Changes for docs/openapi.yaml: 16 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -339,6 +339,7 @@ components:
      allOf:
        - $ref: '#/components/schemas/ServerInfo'
        - type: object
          required: [owned, claimable]
          properties:
            owner:
              type: string
@@ -346,6 +347,21 @@ components:
                The owner's display identity (email, or username when the address is
                absent). Absent for an unclaimed server or when the best-effort owner
                lookup failed.
            owned:
              type: boolean
              description: >-
                True when the caller claimed this server, decided by account id so an
                owner without an email is still recognized.
            claimable:
              type: boolean
              description: >-
                True for a live, unclaimed, non-system server, the same rule the claim
                route enforces. False whenever ownership is unknown.
            ownerUnknown:
              type: boolean
              description: >-
                Present and true when the owner lookup failed, so an absent owner says
                nothing about whether the server is claimed.
            system:
              type: boolean
              description: >-
+56 −21
Changes for internal/api/api_test.go: 56 added lines, 21 removed lines.
Original line number Diff line number Diff line
@@ -33,10 +33,10 @@ type fakeRepo struct {
	// the account_links-bridged web view of the same data.
	allowUUID map[string]map[string]bool
	mine      map[string][]MyServerView
	// owners mirrors the ServerOwners join (name -> owner display identity); only
	// claimed servers appear. ownersErr forces the lookup to fail so a test can
	// prove the fleet read degrades to owner-less rows rather than 500ing.
	owners    map[string]string
	// owners mirrors the ServerOwners join (name -> claim state); a live unclaimed
	// server appears with an empty OwnerID. ownersErr forces the lookup to fail so
	// a test can prove the fleet read degrades rather than 500ing.
	owners    map[string]ServerOwnership
	ownersErr error
	claimOK   map[string]bool // name -> claim succeeds; absent name -> ErrNotFound
	// claimQuotaRefuse simulates ClaimServer's atomic quota gate (audit #4)
@@ -255,7 +255,7 @@ func newFakeRepo() *fakeRepo {
		linked: map[string]bool{}, quota: map[string]bool{},
		allowlist: map[string]map[string]bool{}, allowUUID: map[string]map[string]bool{},
		mine:    map[string][]MyServerView{},
		owners:  map[string]string{},
		owners:  map[string]ServerOwnership{},
		claimOK: map[string]bool{}, claimQuotaRefuse: map[string]bool{},
		serverResources: map[string]ResourceSpec{}, resourceUpdates: map[string]ResourceSpec{},
		seeded: map[string]bool{}, aliases: map[string]string{},
@@ -750,7 +750,7 @@ func (f *fakeRepo) MyServers(_ context.Context, u string) ([]MyServerView, error
	// into the slice it gets.
	return append([]MyServerView(nil), f.mine[u]...), nil
}
func (f *fakeRepo) ServerOwners(_ context.Context) (map[string]string, error) {
func (f *fakeRepo) ServerOwners(_ context.Context) (map[string]ServerOwnership, error) {
	if f.ownersErr != nil {
		return nil, f.ownersErr
	}
@@ -1971,11 +1971,13 @@ func TestFleetAdminRead(t *testing.T) {
	})

	// fleetRow mirrors the on-the-wire fleetServerView: the lifecycle fields plus
	// the presentational owner join. A server absent from ServerOwners (unclaimed)
	// or a failed lookup must serialize owner as "" (omitempty drops it).
	// the ownership join.
	type fleetRow struct {
		Name         string `json:"name"`
		Owner        string `json:"owner"`
		Owned        bool   `json:"owned"`
		Claimable    bool   `json:"claimable"`
		OwnerUnknown bool   `json:"ownerUnknown"`
	}
	adminAPI := func(repo *fakeRepo) *API {
		api := newTestAPI(repo, cl)
@@ -2042,33 +2044,66 @@ func TestFleetAdminRead(t *testing.T) {
		}
	})

	t.Run("owner merges for claimed, absent for unclaimed", func(t *testing.T) {
	t.Run("ownership comes from the account id", func(t *testing.T) {
		repo := newFakeRepo()
		// Only "survival" is claimed; "creative"/"skyblock" stay unowned.
		repo.owners["survival"] = "[email protected]"
		byName := map[string]string{}
		// The caller (a1) owns "survival" but has no email, so its display is the
		// username; "creative" belongs to someone else; "skyblock" is unclaimed.
		repo.owners["survival"] = ServerOwnership{OwnerID: "a1", Owner: "a1-username"}
		repo.owners["creative"] = ServerOwnership{OwnerID: "u2", Owner: "[email protected]"}
		repo.owners["skyblock"] = ServerOwnership{}
		byName := map[string]fleetRow{}
		for _, r := range readFleet(t, adminAPI(repo)) {
			byName[r.Name] = r.Owner
			byName[r.Name] = r
		}
		if byName["survival"] != "[email protected]" {
			t.Fatalf("survival owner = %q, want [email protected]", byName["survival"])
		want := map[string]fleetRow{
			"survival": {Name: "survival", Owner: "a1-username", Owned: true},
			"creative": {Name: "creative", Owner: "[email protected]"},
			"skyblock": {Name: "skyblock", Claimable: true},
		}
		if byName["creative"] != "" {
			t.Fatalf("creative owner = %q, want empty (unclaimed)", byName["creative"])
		for name, w := range want {
			if byName[name] != w {
				t.Errorf("%s = %+v, want %+v", name, byName[name], w)
			}
		}
	})

	t.Run("a server without a business row cannot be claimed", func(t *testing.T) {
		// A CRD the servers table does not know (or a soft-deleted row) answers a
		// claim with 404, so the row must not offer one.
		rows := readFleet(t, adminAPI(newFakeRepo()))
		for _, r := range rows {
			if r.Claimable || r.Owned || r.OwnerUnknown {
				t.Errorf("%s = %+v, want no claim state (no business row)", r.Name, r)
			}
		}
	})

	t.Run("system services are never claimable", func(t *testing.T) {
		sysCl := newFakeCluster()
		sysCl.list = []ServerInfo{{Name: "lobby", Phase: "Running", Ready: true}}
		repo := newFakeRepo()
		repo.owners["lobby"] = ServerOwnership{}
		api := newTestAPI(repo, sysCl)
		api.External = staticExternal{p: &Principal{UserID: "a1", Email: "[email protected]",
			Role: "admin", ViaAdminAccess: true}}
		rows := readFleet(t, api)
		if len(rows) != 1 || rows[0].Claimable {
			t.Fatalf("rows = %+v, want lobby present and not claimable", rows)
		}
	})

	t.Run("owner lookup failure degrades to owner-less rows", func(t *testing.T) {
	t.Run("owner lookup failure marks ownership unknown", func(t *testing.T) {
		repo := newFakeRepo()
		repo.owners["survival"] = "[email protected]" // would merge, but the lookup errors
		repo.owners["survival"] = ServerOwnership{OwnerID: "u2", Owner: "[email protected]"} // would merge, but the lookup errors
		repo.owners["skyblock"] = ServerOwnership{}
		repo.ownersErr = fmt.Errorf("postgres unreachable")
		rows := readFleet(t, adminAPI(repo)) // must still be 200, not 500
		if len(rows) != 3 {
			t.Fatalf("servers = %d, want 3 (a Postgres blip must not drop the fleet)", len(rows))
		}
		for _, r := range rows {
			if r.Owner != "" {
				t.Fatalf("%s owner = %q, want empty (owner lookup failed → degrade)", r.Name, r.Owner)
			if r.Owner != "" || r.Claimable || r.Owned || !r.OwnerUnknown {
				t.Fatalf("%s = %+v, want owner unknown and nothing to claim", r.Name, r)
			}
		}
	})
+23 −9
Changes for internal/api/handlers_user.go: 23 added lines, 9 removed lines.
Original line number Diff line number Diff line
@@ -296,15 +296,20 @@ func (a *API) handleFleet(w http.ResponseWriter, r *http.Request) {
		writeError(w, r, err)
		return
	}
	// Owner is presentational and best-effort. The cockpit exists for the lifecycle
	// view, so a Postgres hiccup must degrade to owner-less rows, never 500 the whole
	// fleet: a lookup error is swallowed and owners stays nil, leaving every row's
	// Owner "" (a nil map reads as zero values).
	owners, _ := a.Repo.ServerOwners(r.Context())
	// Ownership is best-effort. The cockpit exists for the lifecycle view, so a
	// Postgres hiccup must never 500 the whole fleet; the rows say the owner is
	// unknown instead, and none offers a claim that may already be taken.
	p := principalFromContext(r.Context())
	owners, err := a.Repo.ServerOwners(r.Context())
	unknown := err != nil
	views := make([]fleetServerView, len(servers))
	for i, s := range servers {
		views[i] = fleetServerView{ServerInfo: s, Owner: owners[s.Name],
			System: naming.IsSystemServer(s.Name)}
		o, known := owners[s.Name]
		system := naming.IsSystemServer(s.Name)
		views[i] = fleetServerView{ServerInfo: s, Owner: o.Owner, System: system,
			Owned:        o.OwnerID != "" && o.OwnerID == p.UserID,
			Claimable:    known && o.OwnerID == "" && !system,
			OwnerUnknown: unknown}
	}
	writeJSON(w, http.StatusOK, map[string]any{"servers": views})
}
@@ -316,9 +321,18 @@ func (a *API) handleFleet(w http.ResponseWriter, r *http.Request) {
type fleetServerView struct {
	ServerInfo
	// Owner is the claiming user's display identity (email, or username when the
	// address is absent), or "" when the server is unclaimed or the best-effort
	// owner lookup failed — the cockpit renders "" as "unclaimed".
	// address is absent), or "" when the server is unclaimed or the owner lookup
	// failed (OwnerUnknown tells the two apart).
	Owner string `json:"owner,omitempty"`
	// Owned is true when the caller claimed this server, decided by account id so
	// an owner without an email is still recognized.
	Owned bool `json:"owned"`
	// Claimable is true for a live, unclaimed, non-system server: the same rule
	// ClaimServer enforces. It is false whenever ownership is unknown.
	Claimable bool `json:"claimable"`
	// OwnerUnknown is true when the best-effort owner lookup failed, so an empty
	// Owner says nothing about whether the server is claimed.
	OwnerUnknown bool `json:"ownerUnknown,omitempty"`
	// System marks a platform-provisioned system service (the login gate and the
	// lobby, naming.IsSystemServer). Their names are reserved, so every per-server
	// API route rejects them — the cockpit must render them read-only rather than
+14 −14
Changes for internal/api/pgrepo.go: 14 added lines, 14 removed lines.
Original line number Diff line number Diff line
@@ -609,29 +609,29 @@ func (p *PGRepo) MyServers(ctx context.Context, userID string) ([]MyServerView,
	return out, rows.Err()
}

// ServerOwners returns name -> owner display identity for every currently-owned,
// non-deleted server (the SysAdmin cockpit's fleet read). The INNER JOIN drops
// unclaimed servers (owner_id NULL) and the deleted_at filter drops soft-deleted
// ones, so the map holds only servers that have a live owner — the cockpit reads a
// missing key as "no owner". The display value prefers the recognizable email
// (the same identity the audit log records as the human actor, §6) and falls back
// to the never-NULL username when the address is absent.
func (p *PGRepo) ServerOwners(ctx context.Context) (map[string]string, error) {
	const q = `SELECT s.name, COALESCE(NULLIF(u.email, ''), u.username)
		FROM servers s JOIN users u ON u.id = s.owner_id
// ServerOwners returns name -> claim state for every non-deleted server (the
// SysAdmin cockpit's fleet read). The LEFT JOIN keeps unclaimed servers (owner_id
// NULL) with an empty OwnerID, and the deleted_at filter drops soft-deleted ones.
// The display value prefers the recognizable email (the same identity the audit
// log records as the human actor, §6) and falls back to the never-NULL username
// when the address is absent.
func (p *PGRepo) ServerOwners(ctx context.Context) (map[string]ServerOwnership, error) {
	const q = `SELECT s.name, COALESCE(s.owner_id, ''), COALESCE(NULLIF(u.email, ''), u.username, '')
		FROM servers s LEFT JOIN users u ON u.id = s.owner_id
		WHERE s.deleted_at IS NULL`
	rows, err := p.db.QueryContext(ctx, q)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := make(map[string]string)
	out := make(map[string]ServerOwnership)
	for rows.Next() {
		var name, owner string
		if err := rows.Scan(&name, &owner); err != nil {
		var name string
		var o ServerOwnership
		if err := rows.Scan(&name, &o.OwnerID, &o.Owner); err != nil {
			return nil, err
		}
		out[name] = owner
		out[name] = o
	}
	return out, rows.Err()
}
+19 −9
Changes for internal/api/repo.go: 19 added lines, 9 removed lines.
Original line number Diff line number Diff line
@@ -38,6 +38,17 @@ type MyServerView struct {
	PlayerCountUnknown bool   `json:"playerCountUnknown,omitempty"`
}

// ServerOwnership is one live server's claim state as the fleet read joins it.
type ServerOwnership struct {
	// OwnerID is the claiming account, "" while the server is unclaimed. The fleet
	// compares it with the caller's id: an account without an email shows its
	// username as Owner, so the display text cannot say whose server it is.
	OwnerID string
	// Owner is the claiming account's display identity (email, or username when
	// the address is absent), "" while unclaimed.
	Owner string
}

// AuditEntry is one row written to audit_logs (spec §6). Actor is display text:
// a verified email or the username for people (auditActor), the component name
// for internal callers. ActorUserID is the account that acted, the column to
@@ -286,15 +297,14 @@ type Repo interface {
	RecordJoin(ctx context.Context, name, mcUUID string) error
	// MyServers lists the servers a user owns or may claim.
	MyServers(ctx context.Context, userID string) ([]MyServerView, error)
	// ServerOwners maps each currently-owned server to its owner's display identity
	// (email, or username when the address is absent), for the SysAdmin cockpit's
	// fleet read. It is a READ-ONLY presentational join: owner stays authored in
	// Postgres (§6 business authority) and is never written back to the CRD, so this
	// does not breach §1's store-of-record split. Unclaimed and soft-deleted servers
	// are simply absent from the map, so a missing key reads as "no owner". The
	// cockpit treats it as best-effort — a lookup error degrades to owner-less rows
	// rather than failing the fleet read — so callers may ignore the error.
	ServerOwners(ctx context.Context) (map[string]string, error)
	// ServerOwners maps every live server to its claim state, for the SysAdmin
	// cockpit's fleet read. It is a READ-ONLY join: owner stays authored in Postgres
	// (§6 business authority) and is never written back to the CRD, so this does not
	// breach §1's store-of-record split. An unclaimed server is present with an empty
	// OwnerID; a soft-deleted one, or a CRD with no business row, is absent, and
	// cannot be claimed. The cockpit treats it as best-effort: a lookup error leaves
	// ownership unknown rather than failing the fleet read.
	ServerOwners(ctx context.Context) (map[string]ServerOwnership, error)
	// AllBackups lists every present world backup, newest first (spec §7 GET
	// /backups, admin scope). Expired/deleted rows are never returned.
	AllBackups(ctx context.Context) ([]BackupView, error)
Loading