feat: customize login and lobby spaces from the panel

This commit is contained in:
Lemon-miaow committed 2026-10-04 12:38:39 +08:00
1 parent efbbe27629
commit 10bd2ddad0
54 files changed
+1146 -158

No files matched your search

+2 -1
View File
@@ -26,6 +26,7 @@ import (
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/fileedit"
"felis.lolicon.best/internal/naming"
)
// API holds the dependencies shared by every handler.
@@ -1197,7 +1198,7 @@ func (l *streamLimiter) release(key string) {
// reconcile is idempotent and the §18 reaper / §9.3 quota bound steady-state
// load; the cap exists to refuse an obvious flood, not to hold a hard ceiling.
func (a *API) withinRunningCap(ctx context.Context, info *ServerInfo) (bool, error) {
if a.MaxRunningServers <= 0 {
if a.MaxRunningServers <= 0 || naming.IsSystemServer(info.Name) {
return true, nil
}
if info.DesiredState == string(v1alpha1.DesiredRunning) {
+2 -3
View File
@@ -21,7 +21,6 @@ import (
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/fileedit"
"felis.lolicon.best/internal/maintenance"
"felis.lolicon.best/internal/naming"
"felis.lolicon.best/internal/worldexport"
)
@@ -605,11 +604,11 @@ func errExportUnavailable() error {
func (a *API) exportGate(w http.ResponseWriter, r *http.Request) (string, *ServerRecord, bool) {
p := principalFromContext(r.Context())
name := r.PathValue("name")
if err := naming.ValidateServerName(name); err != nil {
if err := validateManagedServerName(r, name); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
return "", nil, false
}
rec, err := a.Repo.ServerByName(r.Context(), name)
rec, err := a.managedServerRecord(r.Context(), name)
if err != nil {
a.writeLookupError(w, r, err)
return "", nil, false
+2 -4
View File
@@ -7,8 +7,6 @@ import (
"regexp"
"strconv"
"strings"
"felis.lolicon.best/internal/naming"
)
// Access / permissions domain (spec §7). These endpoints let an owner manage
@@ -101,12 +99,12 @@ func (a *API) issueLuckPermsCommand(w http.ResponseWriter, r *http.Request, name
// the path, not the body) is validated here.
func (a *API) issueAccessCommand(w http.ResponseWriter, r *http.Request, name, command string) (string, bool) {
p := principalFromContext(r.Context())
if err := naming.ValidateServerName(name); err != nil {
if err := validateManagedServerName(r, name); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
return "", false
}
rec, err := a.Repo.ServerByName(r.Context(), name)
rec, err := a.managedServerRecord(r.Context(), name)
if err != nil {
a.writeLookupError(w, r, err)
return "", false
+4 -4
View File
@@ -183,7 +183,7 @@ func restoreMayRead(jobs []AsyncJob, id string) bool {
func (a *API) handleRestoreBackup(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
name := r.PathValue("name")
if err := naming.ValidateServerName(name); err != nil {
if err := validateManagedServerName(r, name); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
return
}
@@ -191,7 +191,7 @@ func (a *API) handleRestoreBackup(w http.ResponseWriter, r *http.Request) {
// Ownership: owner or admin, mirroring handleStop. An unknown server is 404; an
// unowned (released) server fails the owner check for everyone but admin, which
// is exactly the "must re-claim first" rule.
rec, err := a.Repo.ServerByName(r.Context(), name)
rec, err := a.managedServerRecord(r.Context(), name)
if err != nil {
a.writeLookupError(w, r, err)
return
@@ -367,12 +367,12 @@ func (a *API) handleRestoreBackup(w http.ResponseWriter, r *http.Request) {
func (a *API) handleBackupNow(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
name := r.PathValue("name")
if err := naming.ValidateServerName(name); err != nil {
if err := validateManagedServerName(r, name); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
return
}
rec, err := a.Repo.ServerByName(r.Context(), name)
rec, err := a.managedServerRecord(r.Context(), name)
if err != nil {
a.writeLookupError(w, r, err)
return
+2 -4
View File
@@ -3,8 +3,6 @@ package api
import (
"errors"
"net/http"
"felis.lolicon.best/internal/naming"
)
// maxConsoleCommandLen caps the command body well under RCON's single-packet
@@ -39,7 +37,7 @@ type commandRequest struct {
func (a *API) handleCommand(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
name := r.PathValue("name")
if err := naming.ValidateServerName(name); err != nil {
if err := validateManagedServerName(r, name); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
return
}
@@ -58,7 +56,7 @@ func (a *API) handleCommand(w http.ResponseWriter, r *http.Request) {
}
// Ownership: owner or admin, mirroring handleStop. An unknown server is 404.
rec, err := a.Repo.ServerByName(r.Context(), name)
rec, err := a.managedServerRecord(r.Context(), name)
if err != nil {
a.writeLookupError(w, r, err)
return
+2 -3
View File
@@ -11,7 +11,6 @@ import (
"felis.lolicon.best/internal/fileedit"
"felis.lolicon.best/internal/maintenance"
"felis.lolicon.best/internal/naming"
)
// A file too big for the one-request upload (handleUploadFile) arrives as an
@@ -430,11 +429,11 @@ func (a *API) requireFileStage(w http.ResponseWriter, r *http.Request) (string,
// staff. It returns the server name.
func (a *API) authorizeServerFiles(w http.ResponseWriter, r *http.Request) (string, bool) {
name := r.PathValue("name")
if err := naming.ValidateServerName(name); err != nil {
if err := validateManagedServerName(r, name); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
return "", false
}
rec, err := a.Repo.ServerByName(r.Context(), name)
rec, err := a.managedServerRecord(r.Context(), name)
if err != nil {
a.writeLookupError(w, r, err)
return "", false
+3 -5
View File
@@ -4,8 +4,6 @@ import (
"context"
"errors"
"net/http"
"felis.lolicon.best/internal/naming"
)
// handleServerConsole streams the caller's server console as Server-Sent Events
@@ -39,13 +37,13 @@ import (
func (a *API) handleServerConsole(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
name := r.PathValue("name")
if err := naming.ValidateServerName(name); err != nil {
if err := validateManagedServerName(r, name); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
return
}
// Ownership: owner or admin, mirroring handleCommand. An unknown server is 404.
rec, err := a.Repo.ServerByName(r.Context(), name)
rec, err := a.managedServerRecord(r.Context(), name)
if err != nil {
a.writeLookupError(w, r, err)
return
@@ -105,7 +103,7 @@ func (a *API) handleServerConsole(w http.ResponseWriter, r *http.Request) {
a.audit(r, "console.attach", name)
relayLogStream(w, r, src, a.streamRecheck(r, func(ctx context.Context, p *Principal) error {
rec, err := a.Repo.ServerByName(ctx, name)
rec, err := a.managedServerRecord(ctx, name)
switch {
case errors.Is(err, ErrNotFound):
return errForbidden // the server is gone, and the grant with it
+13 -6
View File
@@ -20,7 +20,7 @@ import (
func (a *API) handleWake(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
name := r.PathValue("name")
if err := naming.ValidateServerName(name); err != nil {
if err := validateManagedServerName(r, name); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
return
}
@@ -97,12 +97,12 @@ func (a *API) handleWake(w http.ResponseWriter, r *http.Request) {
func (a *API) handleStop(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
name := r.PathValue("name")
if err := naming.ValidateServerName(name); err != nil {
if err := validateManagedServerName(r, name); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
return
}
rec, err := a.Repo.ServerByName(r.Context(), name)
rec, err := a.managedServerRecord(r.Context(), name)
if err != nil {
a.writeLookupError(w, r, err)
return
@@ -217,7 +217,7 @@ func (a *API) claimResources(ctx context.Context, name string) (ResourceSpec, er
// handleStatus returns the CRD status view (spec §7 GET /servers/{name}/status).
func (a *API) handleStatus(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if err := naming.ValidateServerName(name); err != nil {
if err := validateManagedServerName(r, name); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
return
}
@@ -899,7 +899,7 @@ const (
// the adminOnly wrapper in routing — every caller here is already an admin.
func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if err := naming.ValidateServerName(name); err != nil {
if err := validateManagedServerName(r, name); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
return
}
@@ -910,6 +910,13 @@ func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) {
return
}
if naming.IsSystemServer(name) &&
((body.AutostartPolicy != nil && *body.AutostartPolicy != string(v1alpha1.AutostartPublic)) ||
(body.IdleStopSeconds != nil && *body.IdleStopSeconds != 0)) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "system services must remain public and exempt from idle stop"))
return
}
// An empty patch is a client mistake, not a no-op success.
if body.DisplayName == nil && body.AutostartPolicy == nil && body.Image == nil &&
body.Memory == nil && body.Resources == nil && body.Storage == nil && body.IdleStopSeconds == nil {
@@ -1073,7 +1080,7 @@ func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) {
// fits (ResizeServer). Only growth is held to the caps: a change that grows
// neither CPU nor memory cannot push the owner past one, and it is how an admin
// brings a server back under a cap lowered below what the owner already uses.
if resUpdated {
if resUpdated && !naming.IsSystemServer(name) {
newCPU := quantityToMilli(newResources.Limits[corev1.ResourceCPU])
newMemMB := quantityToMB(newResources.Limits[corev1.ResourceMemory])
+2 -4
View File
@@ -4,8 +4,6 @@ import (
"context"
"net/http"
"time"
"felis.lolicon.best/internal/naming"
)
// AsyncJob is the observable outcome of one asynchronous world operation. The API
@@ -45,11 +43,11 @@ type JobStatusReader interface {
func (a *API) handleServerJobs(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
name := r.PathValue("name")
if err := naming.ValidateServerName(name); err != nil {
if err := validateManagedServerName(r, name); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
return
}
rec, err := a.Repo.ServerByName(r.Context(), name)
rec, err := a.managedServerRecord(r.Context(), name)
if err != nil {
a.writeLookupError(w, r, err)
return
+28
View File
@@ -0,0 +1,28 @@
package api
import (
"context"
"net/http"
"felis.lolicon.best/internal/naming"
)
// System services have no player owner or business-layer row. Staff manage their
// existing cluster objects; creation and claiming keep the reserved-name gate.
func validateManagedServerName(r *http.Request, name string) error {
if p := principalFromContext(r.Context()); naming.IsSystemServer(name) && p != nil && p.IsAdmin() {
return naming.ValidateSystemServerName(name)
}
return naming.ValidateServerName(name)
}
func (a *API) managedServerRecord(ctx context.Context, name string) (*ServerRecord, error) {
if !naming.IsSystemServer(name) {
return a.Repo.ServerByName(ctx, name)
}
info, err := a.Cluster.GetServer(ctx, name)
if err != nil {
return nil, err
}
return &ServerRecord{Name: name, Subdomain: info.Subdomain}, nil
}
+123
View File
@@ -0,0 +1,123 @@
package api
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
)
func TestSystemServerManagement(t *testing.T) {
for _, name := range []string{"login", "lobby"} {
for _, principal := range []*Principal{
{UserID: "staff", Role: "admin", ViaAdminAccess: true},
{UserID: "staff", Role: "admin"},
{UserID: "player", Role: "user"},
} {
t.Run(fmt.Sprintf("%s/%s/operator=%t", name, principal.Role, principal.ViaAdminAccess), func(t *testing.T) {
api, repo, cl, files := mkFiles(t)
api.External = staticExternal{p: principal}
cl.byName[name] = &ServerInfo{Name: name, Subdomain: name, Phase: "Stopped", DesiredState: "Stopped", ReaperExempt: true}
// These services are cluster-owned; there deliberately is no database row.
for _, route := range []struct {
method, suffix, body string
success int
}{
{"GET", "/status", "", 200},
{"GET", "/files", "", 200},
{"GET", "/file?path=felis-experience.json", "", 200},
{"PUT", "/file?path=felis-experience.json", `{"content":"aGk=","content_sha256":"` + hiSum + `"}`, 200},
{"PATCH", "", `{"displayName":"Custom Space"}`, 200},
{"POST", "/stop", "", 202},
} {
before := files.calls
result := do(api.ExternalHandler(), route.method, "/api/v1/servers/"+name+route.suffix, route.body, jsonHeader)
if principal.IsAdmin() {
if result.Code != route.success {
t.Fatalf("%s %s: %d %s", route.method, route.suffix, result.Code, result.Body.String())
}
} else {
if result.Code < 400 || files.calls != before {
t.Fatalf("player management admitted: %d %s", result.Code, result.Body.String())
}
}
}
result := do(api.ExternalHandler(), "POST", "/api/v1/servers/"+name+"/claim", "", nil)
if result.Code != http.StatusBadRequest {
t.Fatalf("system service claim: %d", result.Code)
}
if _, exists := repo.byName[name]; exists {
t.Fatal("management created a claimable business row")
}
})
}
}
}
func TestSystemServerResourcesAndInvariants(t *testing.T) {
api, repo, cl, _ := newPatchAPI()
cl.byName["lobby"] = &ServerInfo{Name: "lobby", ReaperExempt: true, Resources: corev1.ResourceRequirements{
Limits: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("1Gi")},
Requests: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("1Gi")},
}}
result := do(api.ExternalHandler(), "PATCH", "/api/v1/servers/lobby", `{"memory":"2Gi"}`, jsonHeader)
if result.Code != http.StatusOK || cl.patched["lobby"].JavaMemory == nil {
t.Fatalf("resource patch: %d %s", result.Code, result.Body.String())
}
if _, exists := repo.byName["lobby"]; exists {
t.Fatal("system resources added an ownership row")
}
for _, body := range []string{`{"autostartPolicy":"ownerOnly"}`, `{"idleStopSeconds":600}`} {
result := do(api.ExternalHandler(), "PATCH", "/api/v1/servers/lobby", body, jsonHeader)
if result.Code != http.StatusBadRequest {
t.Fatalf("system invariant changed: %d %s", result.Code, result.Body.String())
}
}
delete(cl.byName, "lobby")
result = do(api.ExternalHandler(), "POST", "/api/v1/servers/lobby/stop", "", nil)
if result.Code != http.StatusNotFound {
t.Fatalf("missing cluster service: %d %s", result.Code, result.Body.String())
}
}
func TestSystemLobbyBuilderAccess(t *testing.T) {
api, repo, cl, console := mkAccess(t)
api.External = staticExternal{p: &Principal{UserID: "staff", Role: "admin", ViaAdminAccess: true}}
cl.byName["lobby"] = &ServerInfo{Name: "lobby", Phase: "Running", Ready: true}
result := do(api.ExternalHandler(), "POST", "/api/v1/servers/lobby/access/permission",
`{"action":"set","player":"Steve","node":"felis.lobby.build","value":true}`, jsonHeader)
if result.Code != http.StatusOK || console.gotCommand != "lp user Steve permission set felis.lobby.build true" {
t.Fatalf("builder grant: %d %s; command %q", result.Code, result.Body.String(), console.gotCommand)
}
if _, exists := repo.byName["lobby"]; exists {
t.Fatal("builder access created an ownership row")
}
}
func TestSystemConsoleStreamRechecksWithoutOwnershipRow(t *testing.T) {
shrinkStreamTimers(t, 10*time.Millisecond, 80*time.Millisecond)
a, _, cl, _ := mkAccess(t)
a.External = staticExternal{p: &Principal{UserID: "staff", Role: "admin", ViaAdminAccess: true}}
cl.byName["lobby"] = &ServerInfo{Name: "lobby", Phase: "Running", Ready: true}
a.Logs = &fakeLogStreamer{srcFromCtx: func(ctx context.Context) io.ReadCloser {
return &ctxBlockingReadCloser{ctx: ctx, first: []byte("boot\n"), firstRead: make(chan struct{}), closed: make(chan struct{})}
}}
begun := time.Now()
done := make(chan *httptest.ResponseRecorder, 1)
go func() { done <- do(a.ExternalHandler(), "GET", "/api/v1/servers/lobby/console", "", nil) }()
select {
case result := <-done:
if result.Code != http.StatusOK || strings.Contains(result.Body.String(), "event: revoked") || time.Since(begun) < 80*time.Millisecond {
t.Fatalf("system logs ended before their lifetime: %d %s", result.Code, result.Body.String())
}
case <-time.After(2 * time.Second):
t.Fatal("system log stream outlived its lifetime")
}
}