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

+10 -14
View File
@@ -3659,16 +3659,12 @@ build_velocity_plugin() {
# signed with a matching HMAC. Only protocol 47 was measured; the rest of Via's 1.7-1.12 range
# is its own documented support.
#
# Pinned by hash and not by "latest" on purpose. These three jars sit in front of every packet
# on the proxy, and they are the exact bytes FL-007 measured — a moving tag would quietly make
# this an unmeasured configuration. Bumping a version means bumping its checksum here.
#
# The three versions are a set, not three independent pins. ViaRewind is the component that
# carries 1.8/1.7 support, and 4.1.2 against ViaVersion/ViaBackwards 5.11.0 fails to load
# Protocol1_9To1_8 — the single protocol every 1.8 client needs — with "Invalid version: 1"
# at proxy startup. 4.1.3 is the release that adds 5.11.0 compatibility; a two-arm run of the
# same proxy image logs that error three times on 4.1.2 and not at all on 4.1.3. Read the
# ViaRewind release notes before moving ViaVersion or ViaBackwards.
# Pin the three jars as one compatible set. ViaVersion/ViaBackwards 5.12.0 add
# the 26.3 protocol used by game-stack.lock; ViaRewind 4.2.0 explicitly supports
# that pair. The earlier FL-007 join measured 1.8 against Paper 1.21.11, not every
# client on 26.3. Release notes:
# https://github.com/ViaVersion/ViaBackwards/releases/tag/5.12.0
# https://github.com/ViaVersion/ViaRewind/releases/tag/4.2.0
install_via_plugins() {
prepare_velocity_layout
local name version want target url tmp have
@@ -3694,12 +3690,12 @@ install_via_plugins() {
|| die "${name} ${version} checksum mismatch: got ${have}, expected ${want}"
atomic_install_file "$tmp" "$target" 0644 root root
done <<'EOF'
ViaVersion 5.11.0 18d19e90fc9467d68128c076630ae8700449c901402a3ef421837ce006bc8cae
ViaBackwards 5.11.0 b21983d561e3f92df257683f0133ab6c68ec68175e8acfd82c6231723bf83587
ViaRewind 4.1.3 2d5970d22b4711c9ab2800932326c7b08acdace25ed7c6bbb8f6ea81054962b4
ViaVersion 5.12.0 72c40a6a702d67f226fc9a0d8ad82aba1483fdabe2e6159bcdddb2dc070750b0
ViaBackwards 5.12.0 194e9250224632274d7b3c17e411e031a9223c1863c6f5138d53c721f07ab78d
ViaRewind 4.2.0 d6634ba57bb82d5161c68dfb393571cdf40511a0beb1b04b8c7ed794a3532c6a
EOF
pin_via_block_connections
ok "Via staged; clients from 1.8 up can join under modern forwarding"
ok "Via staged with 26.3 support; verify client versions against your chosen backend images"
}
# pin_via_block_connections turns ViaVersion's serverside block-connection tracking off.
+17
View File
@@ -164,3 +164,20 @@ set them by hand:
The Velocity gate/lobby wiring is printed by `felis setup` and enforces the
invariant: fresh connections hit `login` first, and only an authenticated release
from that gate can enter the post-auth lobby or a remembered user backend.
## Customize in the panel
Administrators open **Login & lobby**, select **Login space**, and stop it before
editing. The form configures the login book title/author/heading/link text/help,
automatic book opening and the login timeout (30–3600 seconds). These settings
persist in `/data/felis-experience.json`; an explicit
`FELIS_LOGIN_TIMEOUT_SECONDS` environment variable takes precedence. The generated
code, generated login URL and chat guidance are preserved. The authentication
and transfer destination are not player-facing customization fields.
Use the linked file manager to upload a replacement `/data/spawn.schem`, edit
Limbo's `server.properties` or add Limbo-compatible plugins, then start the space.
Paper world ZIPs and Paper plugins do not work in Limbo. The page also exposes
logs, backups/restore and image/resource settings. New joins are unavailable
while this front door is stopped; a custom image must retain the login plugin
and support the proxy's forwarding protocol.
+28 -3
View File
@@ -28,7 +28,7 @@ this at every layer:
```
docker build -f deploy/lobby/Dockerfile \
--build-arg PAPER_JAR_URL=https://<mirror>/paper-1.21.x-<build>.jar \
--build-arg PAPER_JAR_URL=https://<mirror>/paper-26.3-<build>.jar \
--build-arg PAPER_JAR_SHA256=<sha256 of that jar> \
-t felis-lobby:demo .
# Publish into the cluster's registry (on the node; docker treats 127.0.0.1 as
@@ -40,6 +40,30 @@ docker push 127.0.0.1:5000/felis/lobby:demo
sudo felis setup
```
## Customize in the panel
Administrators open **Login & lobby** (`/admin/lobby`). Stop the selected space
before reading or saving its settings, then start it to apply them. The lobby
form configures welcome text, menu titles, join behavior, game mode, building
protection, damage/hunger/void handling, difficulty, time/weather and world rules.
Settings live in `/data/felis-experience.json`, independently of the image, and
retain unknown keys when saved. Existing installations without this file use the
same protected-lobby defaults as before.
The page also exposes the existing file manager (including upload and ZIP
extraction), console, backups/restore, builder permissions and image/resource
settings. To replace a map: back up and stop the lobby, upload a world ZIP,
extract it at the volume root, verify the world directory directly contains
`level.dat`, and set `level-name` in `server.properties`. Use `setworldspawn x y z`
in the running lobby console to set its spawn. Plugin JARs go in `plugins/` and
must match Paper's version; the bundled Felis and LuckPerms JARs are refreshed
from the image at boot. A custom image must retain the menu/control plugin.
The operator reuses its `init-forwarding` YAML merge for the lobby, preserving
custom Paper globals while refreshing mandatory authentication settings. The
image only rewrites that file for standalone runs without a managed forwarding
initContainer. RCON secrets and the proxy forwarding secret remain managed.
## Configure (deployer's responsibility)
- Game port must be `25565` (the CRD `GamePort`).
@@ -47,7 +71,7 @@ sudo felis setup
- The lobby speaks only the `felis:control` plugin-message channel; it holds no
felis-api token by design (spec §12).
## What the lobby allows
## Default lobby behavior
felis-paper's `LobbyGuard` keeps the lobby a hub that nobody can hurt, get hurt in,
or leave a mark on:
@@ -65,6 +89,7 @@ or leave a mark on:
LuckPerms (`lp user <name> permission set felis.lobby.build true` on the lobby console)
or op them.
The entrypoint pins `max-players=200` on every boot, over Paper's default of 20: every
The entrypoint seeds `max-players=200` when absent, over Paper's default of 20;
subsequent file-editor changes survive restarts: every
authenticated player passes through here, and a stopped server's players arrive all at
once.
+12 -9
View File
@@ -76,7 +76,10 @@ set_prop online-mode false
# what one node serves at once, and a flood beyond it is refused at the door instead
# of running the 1Gi lobby out of memory. What the world itself allows (no damage, no
# building, the /menu hint) is felis-paper's LobbyGuard.
set_prop max-players 200
# Seed capacity once; administrators can tune it in the panel file editor.
if ! grep -q '^max-players=' "$PROPS"; then
set_prop max-players 200
fi
# RCON is the control plane's write channel (spec §8 写=RCON): the operator probes it
# for readiness and the player tally, and felis-api runs console/permission commands over
@@ -105,21 +108,21 @@ else
echo " injects it from the <server>-rcon Secret when spec.rcon.enabled is true." >&2
fi
# Rewritten whole, not merged. Paper loads this file and fills every key it does
# not find with the default, then writes the full tree back — so a proxies-only file is a
# complete, stable input, and the lobby's other globals are simply always the defaults.
# That is true of a system server Felis owns end to end; if admins are ever allowed to tune
# the lobby's globals, this has to become a real YAML merge (yq) instead.
# The operator's existing init-forwarding step merges the proxy keys on every
# start, preserving other Paper globals. Standalone runs retain the mandatory
# rewrite because no initContainer has verified their forwarding settings.
mkdir -p config
cat > config/paper-global.yml <<YAML
# Written by felis-lobby's entrypoint on every boot. Do not hand-edit: the forwarding
# secret is injected from the felis-forwarding-secret Secret and must match the proxy.
if [ "${FELIS_MANAGED_FORWARDING:-false}" = true ]; then
[ -f config/paper-global.yml ] || { echo "felis-lobby: missing managed forwarding config" >&2; exit 1; }
else
cat > config/paper-global.yml <<YAML
proxies:
velocity:
enabled: true
online-mode: true
secret: "${SECRET}"
YAML
fi
echo "felis-lobby: server-port=${PORT}, velocity modern forwarding on (UUIDs are Mojang-verified)"
JAVA_MEMORY_ARG=""
+18 -6
View File
@@ -62,6 +62,12 @@ info:
title: felis-api
version: 4.1.0
description: |
Staff on the operator console may manage the reserved login/lobby system
services through existing management routes, without player ownership rows.
Creating and claiming these reserved names remain prohibited. System patches
keep public autostart and idle stop disabled. Customization is persisted as
felis-experience.json using the existing stopped-server file API.
Control plane for the Felis Minecraft orchestration platform. The same binary
exposes an internal face (per-caller service tokens, for velocity / backend
callbacks, never Zero Trust) and an external face (the felis_session cookie, for
@@ -600,9 +606,9 @@ components:
type: boolean
description: >-
True for a platform-provisioned system service (the login gate, the
lobby). Their reserved names are rejected by every per-server route,
so the cockpit renders them read-only instead of offering actions
that would 400.
lobby). Staff can manage them through the existing server routes;
players see them read-only. Creating, claiming and deleting these
reserved names remain prohibited.
RetireState:
type: object
@@ -2437,6 +2443,8 @@ paths:
properties:
name: { type: string }
desiredState: { type: string, const: Stopped }
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
@@ -2465,6 +2473,8 @@ paths:
properties:
name: { type: string }
claimed: { type: boolean, const: true }
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
@@ -3182,6 +3192,8 @@ paths:
content:
application/json:
schema: { $ref: '#/components/schemas/ServerInfo' }
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
@@ -4488,7 +4500,7 @@ paths:
recorded when it was written as it streams; a mismatch cuts the
download off short of its end. On the way out config/paper-global.yml
(the cluster's forwarding secret) is left out and server.properties has
its rcon.password redacted, so the download carries no Content-Length.
its rcon.password and forwarding-secrets redacted, so the download carries no Content-Length.
A user gets 404 for a backup outside their scope, as their list never
shows it. One export per user at a time, 2 across the install, 6 per
user per hour.
@@ -4850,7 +4862,7 @@ paths:
type: string
pattern: '^[0-9a-f]{64}$'
description: >-
SHA-256 of the file as stored (before the rcon.password redaction in
SHA-256 of the file as stored (before secret redaction in
server.properties). Send it back as expect_sha256 on the next write.
content_sha256:
type: string
@@ -5204,7 +5216,7 @@ paths:
without one, with symbolic links, devices and sockets left out.
config/paper-global.yml, the cluster's forwarding secret, is refused as
a file and left out of a folder, and server.properties goes out with
its rcon.password redacted; both are matched by the file itself, so a
its rcon.password and forwarding-secrets redacted; both are matched by the file itself, so a
link to either under another name is guarded too. The server cannot
start until the download has ended. Two file downloads per user at a
time, 4 across the install, 30 per user per hour, counted apart from
+4 -4
View File
@@ -9,7 +9,7 @@ import (
)
// The lobby and login gate take their player cap from server.properties, which the
// entrypoint rewrites on every boot over whatever the volume already holds. These run
// login gate rewrites on every boot; the lobby seeds it only when absent. These run
// the shipped entrypoints the way a pod does (image and volume paths pointed into temp
// dirs, java replaced by a stub that exits) and read the file the server would start on.
@@ -91,11 +91,11 @@ func assertProp(t *testing.T, props, key, want string) {
var lobbyImage = []string{"paper.jar", "plugins/felis-paper.jar", "plugins/LuckPerms.jar"}
func TestLobbyEntrypointLiftsThePlayerCap(t *testing.T) {
t.Run("over the cap Paper wrote on an earlier boot", func(t *testing.T) {
func TestLobbyEntrypointSeedsAndPreservesThePlayerCap(t *testing.T) {
t.Run("preserves an administrator capacity", func(t *testing.T) {
props := runEntrypoint(t, "deploy/lobby/entrypoint.sh", `RUNTIME_DIR="/paper"`, lobbyImage,
"#Minecraft server properties\nmax-players=20\nmotd=Kept as it was\n")
assertProp(t, props, "max-players", "200")
assertProp(t, props, "max-players", "20")
assertProp(t, props, "motd", "Kept as it was")
})
t.Run("on a first boot", func(t *testing.T) {
+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")
}
}
+8 -3
View File
@@ -456,7 +456,7 @@ const (
redactedValue = "<redacted by felis>"
)
// redactSecretProps blanks the RCON password when server.properties is read.
// redactSecretProps hides RCON and Limbo forwarding secrets when server.properties is read.
//
// Unlike secretConfigPath this is a value redaction rather than a whole-file
// denial, because the file is not platform material that merely happens to sit in
@@ -468,6 +468,9 @@ const (
// regardless of blast radius, and because the console already gives an owner every
// capability the password would.
//
// Limbo stores its cluster forwarding key in this file too; that value is
// withheld by the same redaction and refreshed by the Limbo entrypoint.
//
// The write path is left alone on purpose, mirroring the reasoning at
// secretConfigPath: felis-lobby's entrypoint rewrites all three rcon keys from the
// injected Secret on every boot, so saving the placeholder back cannot lock the
@@ -481,8 +484,10 @@ func redactSecretProps(name string, content []byte) []byte {
for i, line := range lines {
// TrimSpace before matching: a properties key may be indented, and the
// trailing \r of a CRLF file would otherwise ride along into the value.
if bytes.HasPrefix(bytes.TrimSpace(line), []byte(rconPasswordKey+"=")) {
lines[i] = []byte(rconPasswordKey + "=" + redactedValue)
for _, key := range []string{rconPasswordKey, "forwarding-secrets"} {
if bytes.HasPrefix(bytes.TrimSpace(line), []byte(key+"=")) {
lines[i] = []byte(key + "=" + redactedValue)
}
}
}
return bytes.Join(lines, []byte("\n"))
+20
View File
@@ -678,3 +678,23 @@ func assertNoTemporaries(t *testing.T, dir string) {
}
}
}
func TestReadRedactsLimboForwardingSecret(t *testing.T) {
root := t.TempDir()
props := "spawn-x=8\nforwarding-secrets=shared-key\nvelocity-modern=true\n"
if err := os.WriteFile(filepath.Join(root, "server.properties"), []byte(props), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Link(filepath.Join(root, "server.properties"), filepath.Join(root, "copy.properties")); err != nil {
t.Fatal(err)
}
for _, path := range []string{"server.properties", "copy.properties"} {
res, err := run(root, OpRead, path, nil, "")
if err != nil || res.Code != "" {
t.Fatalf("read: %+v %v", res, err)
}
if strings.Contains(string(res.Content), "shared-key") || !strings.Contains(string(res.Content), "spawn-x=8") {
t.Fatalf("unsafe redaction: %s", res.Content)
}
}
}
+2 -2
View File
@@ -9,7 +9,7 @@ import (
// What leaves a world mount — a read, a download, a world export, a backup
// export — passes the same two guards: the forwarding-secret file
// (secretConfigPath) is withheld, and server.properties has its RCON password
// redacted (propsPath).
// redacted (propsPath), along with Limbo's forwarding-secrets.
//
// On a live mount both are matched by the file itself (os.SameFile), not by the
// name it was reached under. A plugin runs arbitrary code as the game uid and can
@@ -51,7 +51,7 @@ func ArchiveRule(name string) (withhold, redact bool) {
return name == secretConfigPath, name == propsPath
}
// RedactProps replaces the RCON password in server.properties content with
// RedactProps replaces RCON and Limbo forwarding secrets in server.properties with
// redactedValue (see redactSecretProps for why a placeholder and not a blank).
func RedactProps(content []byte) []byte {
return redactSecretProps(propsPath, content)
+2 -5
View File
@@ -48,11 +48,8 @@ const (
SystemLobbyServer = "lobby"
)
// IsSystemServer reports whether name is one of the platform-provisioned system
// services above. They carry reserved names on purpose, and the API's per-server
// routes reject those names outright (ValidateServerName) — so a caller that only
// DISPLAYS fleet rows uses this to mark them as not user-manageable instead of
// offering actions (claim/wake/stop/console) that would answer 400.
// IsSystemServer identifies platform services whose reserved names may be managed
// by staff, but never created or claimed through player-facing routes.
func IsSystemServer(name string) bool {
return name == SystemLoginServer || name == SystemLobbyServer
}
+9 -6
View File
@@ -269,15 +269,18 @@ func buildStatefulSet(server *v1alpha1.MinecraftServer, replicas int32, felisIma
// Every server first hands its world volume to the game uid (prepareDataInitContainer),
// since the pod runs as that uid and a world an older root-run release wrote would
// otherwise be read-only to it. An arbitrary user Paper image then gets the forwarding
// config written for it (it does not consume FELIS_FORWARDING_SECRET itself); system
// servers (login/lobby) are Felis-built and handle forwarding in their own
// entrypoints. Last, every server waits for its egress fence (egressGateInitContainer).
// config written for it (it does not consume FELIS_FORWARDING_SECRET itself).
// The lobby uses the same merge so custom settings survive; the login Limbo
// handles its own properties format. Every server then waits for its egress fence.
// Without a felis image name there is nothing to run any step with.
var initContainers []corev1.Container
if felisImage != "" {
initContainers = append(initContainers, prepareDataInitContainer(felisImage))
if server.Labels[v1alpha1.LabelSystemRole] == "" {
if server.Labels[v1alpha1.LabelSystemRole] != naming.SystemLoginServer {
initContainers = append(initContainers, forwardingInitContainer(felisImage))
if server.Labels[v1alpha1.LabelSystemRole] == naming.SystemLobbyServer {
container.Env = append(container.Env, corev1.EnvVar{Name: "FELIS_MANAGED_FORWARDING", Value: "true"})
}
}
gate := egressGateInitContainer(felisImage)
if server.Spec.NodeName != "" || (len(gateProbe) > 0 && gateProbe[0] != "") {
@@ -447,8 +450,8 @@ func forwardingSecretEnvVar() corev1.EnvVar {
// at all: no capability, a read-only root filesystem, and the files it writes are
// owned by the very uid that rewrites them on boot.
//
// Only user servers get it: the Felis-built system images (login limbo, lobby) already
// consume the secret in their own entrypoints, and the login limbo is not Paper at all.
// User Paper servers and the lobby share this merge. The login Limbo handles
// its own properties format in its entrypoint.
func forwardingInitContainer(felisImage string) corev1.Container {
return corev1.Container{
Name: "init-forwarding",
+14 -5
View File
@@ -89,7 +89,7 @@ func TestReadinessProbeHTTPCustomPath(t *testing.T) {
// A user server (no system-role label) gets the forwarding-config initContainer after
// prepare-data, running the felis image and mounting the world volume. A system
// server gets no forwarding step, and a build with no felis image name gets no step.
// login gate handles its own properties; a build with no felis image name gets no step.
func TestBuildStatefulSetForwardingInitContainer(t *testing.T) {
user := &v1alpha1.MinecraftServer{}
user.Spec.Storage.Size = "1Gi"
@@ -143,14 +143,23 @@ func TestBuildStatefulSetForwardingInitContainer(t *testing.T) {
t.Error("no felis image must yield no initContainer")
}
// System server handles forwarding in its own entrypoint, but its world still
// needs handing to the game uid and its image waits for the fence all the same.
// The lobby shares the forwarding merge, preserving its custom Paper globals.
sys := &v1alpha1.MinecraftServer{}
sys.Spec.Storage.Size = "1Gi"
sys.Labels = map[string]string{v1alpha1.LabelSystemRole: "lobby"}
sysSts, _ := buildStatefulSet(sys, 1, "felis:demo")
if got := sysSts.Spec.Template.Spec.InitContainers; len(got) != 2 || got[0].Name != "prepare-data" || got[1].Name != "egress-gate" {
t.Errorf("system server must get [prepare-data egress-gate], got %+v", got)
if got := sysSts.Spec.Template.Spec.InitContainers; len(got) != 3 || got[0].Name != "prepare-data" || got[1].Name != "init-forwarding" || got[2].Name != "egress-gate" {
t.Errorf("lobby must get [prepare-data init-forwarding egress-gate], got %+v", got)
}
managed := false
for _, env := range sysSts.Spec.Template.Spec.Containers[0].Env {
if env.Name == "FELIS_MANAGED_FORWARDING" && env.Value == "true" {
managed = true
}
}
if !managed {
t.Fatal("lobby entrypoint would overwrite the merged forwarding config")
}
}
+11 -2
View File
@@ -286,6 +286,10 @@ function initialState(): MockState {
server("claim-me", "Claimable Node", "Stopped", null, {
playersMax: 10,
}),
server("login", "Login space", "Stopped", null, {
autostartPolicy: "public", idleStopSeconds: 0, reaperExempt: true,
image: "registry.felis.svc:5000/felis/limbo:demo", memory: "512Mi", storageSize: "1Gi",
}),
...generatedServers(),
],
access: {
@@ -764,7 +768,8 @@ function fleetView(state: MockState, accountInfo: MockAccount): FleetServer[] {
playersOnline: s.ready ? s.playersOnline : 0,
owner: owner ? state.accounts[owner].email : "",
owned: owner === accountInfo.id,
claimable: owner === null && !s.retiring,
claimable: owner === null && !s.retiring && !s.reaperExempt,
system: s.reaperExempt,
};
});
}
@@ -2241,6 +2246,10 @@ async function handleServerRoute(ctx: SessionContext): Promise<boolean> {
sendError(ctx.res, 404, "not_found", "server not found");
return true;
}
if (serverInfo.reaperExempt && !isAdmin(ctx.account.role)) {
sendError(ctx.res, 400, "bad_name", "system service is reserved for staff");
return true;
}
if (!canSee(ctx.account, serverInfo)) {
sendError(ctx.res, 403, "forbidden", "server is not visible to this account");
return true;
@@ -2474,7 +2483,7 @@ async function handleFilesMock(ctx: SessionContext, serverInfo: MockServer): Pro
if (!node) return fail(404, "not_found", `${p} does not exist`);
if (node.is_dir) return fail(400, "bad_path", `${p} is a directory, not a file`);
if (node.data.length > MOCK_MAX_READ) return fail(413, "too_large", `${p} is larger than the editor reads`);
sendJSON(ctx.res, 200, { path: p, content: node.data.toString("base64"), sha256: mockSha(node.data) });
sendJSON(ctx.res, 200, { path: p, content: node.data.toString("base64"), sha256: mockSha(node.data), content_sha256: mockSha(node.data) });
return true;
}
case "PUT file": {
+1
View File
@@ -30,6 +30,7 @@ for (const [account, path] of [
["linked", "/account"],
["owner", "/"],
["owner", "/servers"],
["owner", "/admin/lobby?space=login"],
["owner", "/admin/images"],
["owner", "/admin/builds"],
["owner", "/admin/submissions"],
+11
View File
@@ -50,3 +50,14 @@ test("the LuckPerms player list leaves the rest of the page on the first screen"
await page.getByRole("button", { name: "Herobrine", exact: true }).click();
await expect(page.getByRole("textbox", { name: t("servers:luckperms_player_lookup") })).toHaveValue("Herobrine");
});
test("login customization fits a phone and retains a saved title", async ({ page, signIn }) => {
await signIn("owner");
await page.goto("/admin/lobby?space=login");
await page.getByLabel(t("lobby:bookTitle")).fill("Our Network");
await expectFitsScreen(page);
await page.getByRole("button", { name: t("lobby:save"), exact: true }).click();
await expect(page.getByText(t("lobby:saved"), { exact: true })).toBeVisible();
await page.reload();
await expect(page.getByLabel(t("lobby:bookTitle"))).toHaveValue("Our Network");
});
+20 -1
View File
@@ -98,7 +98,7 @@ test("an unlinked Owner can manage the panel, then preview and confirm a game ro
// Admin pages are chunks of their own, so a player never downloads them: every
// page module is fetched by its name (/src/pages/admin/UsersPage.tsx under the
// dev server, /assets/UsersPage-<hash>.js in a build).
const ADMIN_PAGE = /\/(UsersPage|UserDetailPage|ImageAdmin|ImageBuildPage|SubmissionsPage|UpdatesPage)[.-]/;
const ADMIN_PAGE = /\/(UsersPage|UserDetailPage|LobbyPage|ImageAdmin|ImageBuildPage|SubmissionsPage|UpdatesPage)[.-]/;
const ACCOUNT_PAGE = /\/Account[.-]/;
test("a player's pages load on demand and never pull in the admin pages", async ({ page, signIn }) => {
@@ -130,3 +130,22 @@ test("on a wide screen the LuckPerms player list runs down its column", async ({
await page.setViewportSize({ width: 1280, height: 1000 });
await expect.poll(async () => (await list.locator("..").boundingBox())!.height).toBe(700);
});
test("staff customize the login space through the persistent file editor", async ({ page, signIn }) => {
await signIn("owner");
await page.goto("/admin/lobby?space=login");
await expect(page.getByRole("heading", { name: t("lobby:title"), exact: true })).toBeVisible();
const heading = page.getByLabel(t("lobby:bookHeading"));
await expect(heading).toBeVisible();
await heading.fill("Welcome to our network");
await page.getByRole("button", { name: t("lobby:save"), exact: true }).click();
await expect(page.getByText(t("lobby:saved"), { exact: true })).toBeVisible();
await page.reload();
await expect(heading).toHaveValue("Welcome to our network");
});
test("players cannot open the lobby administration page", async ({ page, signIn }) => {
await signIn("linked");
await page.goto("/admin/lobby");
await expect(page.getByRole("heading", { name: t("lobby:title"), exact: true })).toHaveCount(0);
});
+4
View File
@@ -59,6 +59,9 @@ const UsersPage = lazyWithReload(() =>
const UserDetailPage = lazyWithReload(() =>
import("@/pages/admin/UserDetailPage").then((m) => ({ default: m.UserDetailPage })),
);
const LobbyPage = lazyWithReload(() =>
import("@/pages/admin/LobbyPage").then((m) => ({ default: m.LobbyPage })),
);
const UpdatesPage = lazyWithReload(() =>
import("@/pages/admin/UpdatesPage").then((m) => ({ default: m.UpdatesPage })),
);
@@ -121,6 +124,7 @@ export default function App() {
so the section root and any stale bookmarks land somewhere useful. */}
<Route path="admin" element={<RequireAdmin />}>
<Route index element={<Navigate to="/admin/images" replace />} />
<Route path="lobby" element={<LobbyPage />} />
<Route path="images" element={<ImageAdmin />} />
<Route path="builds" element={<ImageBuildPage />} />
<Route path="submissions" element={<SubmissionsPage />} />
+8 -5
View File
@@ -83,6 +83,7 @@ interface EditServerForm {
interface Props {
serverName: string;
systemService?: boolean;
currentDisplayName?: string;
currentPolicy?: AutostartPolicy;
currentImage?: string;
@@ -97,6 +98,7 @@ interface Props {
export function EditServerDialog({
serverName,
systemService = false,
currentDisplayName = "",
currentPolicy = "ownerOnly",
currentImage = "",
@@ -176,7 +178,7 @@ export function EditServerDialog({
if (displayName !== currentDisplayName) {
payload.displayName = displayName;
}
if (form.autostartPolicy !== currentPolicy) {
if (!systemService && form.autostartPolicy !== currentPolicy) {
payload.autostartPolicy = form.autostartPolicy;
}
if (imageChanged) {
@@ -190,7 +192,7 @@ export function EditServerDialog({
if (cpu !== currentCpu) {
payload.resources = { cpu };
}
if (form.idleStop !== currentIdleStop) {
if (!systemService && form.idleStop !== currentIdleStop) {
payload.idleStopSeconds = Number(form.idleStop);
}
@@ -218,7 +220,7 @@ export function EditServerDialog({
<div className="min-w-0 flex-1 text-left">
<p className="text-sm font-medium">{t("edit_server_title")}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{t("edit_server_desc")}
{t(systemService ? "edit_system_desc" : "edit_server_desc")}
</p>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
@@ -228,7 +230,7 @@ export function EditServerDialog({
<DialogHeader>
<DialogTitle>{t("edit_server_title")}</DialogTitle>
<DialogDescription>
{t("edit_server_desc_long")}
{t(systemService ? "edit_system_desc" : "edit_server_desc_long")}
</DialogDescription>
</DialogHeader>
@@ -350,6 +352,7 @@ export function EditServerDialog({
<Label htmlFor="edit-server-policy">{t("create_server_policy")}</Label>
<Select
value={form.autostartPolicy}
disabled={systemService}
onValueChange={(v) => set("autostartPolicy", v as AutostartPolicy)}
>
<SelectTrigger id="edit-server-policy">
@@ -368,7 +371,7 @@ export function EditServerDialog({
<div className="grid gap-2">
<Label htmlFor="edit-server-idle">{t("edit_server_idle")}</Label>
<Select value={form.idleStop} onValueChange={(v) => set("idleStop", v)}>
<Select disabled={systemService} value={form.idleStop} onValueChange={(v) => set("idleStop", v)}>
<SelectTrigger id="edit-server-idle">
<SelectValue />
</SelectTrigger>
+4
View File
@@ -13,6 +13,8 @@ import enNavigation from "./resources/en-US/navigation.json";
import enBackups from "./resources/en-US/backups.json";
import enSubmissions from "./resources/en-US/submissions.json";
import enFiles from "./resources/en-US/files.json";
import enLobby from "./resources/en-US/lobby.json";
import zhLobby from "./resources/zh-CN/lobby.json";
import enSchedules from "./resources/en-US/schedules.json";
import zhCommon from "./resources/zh-CN/common.json";
import zhAuth from "./resources/zh-CN/auth.json";
@@ -46,6 +48,7 @@ export const i18nOptions: InitOptions = {
submissions: enSubmissions,
files: enFiles,
schedules: enSchedules,
lobby: enLobby,
},
"zh-CN": {
common: zhCommon,
@@ -61,6 +64,7 @@ export const i18nOptions: InitOptions = {
submissions: zhSubmissions,
files: zhFiles,
schedules: zhSchedules,
lobby: zhLobby,
},
},
supportedLngs: [...SUPPORTED_LANGUAGES],
+85
View File
@@ -0,0 +1,85 @@
{
"title": "Login & lobby",
"subtitle": "Customize the scenes, guidance and rules players encounter when joining.",
"space_label": "Choose a space",
"login": "Login space",
"lobby": "Lobby",
"login_description": "Players authenticate here before entering the lobby. This Limbo scene uses a schematic, rather than a Paper world map.",
"lobby_description": "Players choose their server here. Replace maps, install plugins, customize the experience and grant builders access.",
"connection_version": "Installed game version: Java {{version}}",
"unknown_version": "not recorded",
"version_hint": "Client compatibility depends on proxy plugins. Version changes need matching login, lobby and proxy support; this is the installation version, while the running version comes from each image.",
"images_link": "Manage images & versions",
"stop_login": "Stop the login space to read and save settings. New players cannot join during maintenance; start it again when finished.",
"stop_lobby": "Stop the lobby to read and save settings. Back up its map before maintenance, then start it again when finished.",
"read_failed": "Could not read settings: {{reason}}. Check felis-experience.json in the file manager.",
"saved": "Saved. Start this space to apply the settings.",
"save": "Save settings",
"saving": "Saving…",
"invalid_values": "Check numeric ranges and selected options. Text is limited to 512 characters.",
"appearance": "Welcome & menu",
"players": "Player behavior & protection",
"world": "World environment",
"loginBook": "Login guidance & waiting",
"menuTitleZh": "Chinese menu title",
"menuTitleEn": "English menu title",
"welcomeZh": "Chinese welcome message",
"welcomeEn": "English welcome message",
"showWelcome": "Send a welcome message on join",
"openMenuOnJoin": "Open the server menu on join",
"gameMode": "Default player game mode",
"teleportOnJoin": "Return to world spawn on join",
"protectBuild": "Protect against building and damage",
"invulnerable": "Prevent player damage",
"disableHunger": "Prevent hunger",
"voidRescue": "Return to spawn after a void fall",
"difficulty": "Difficulty",
"time": "Fixed time (0–23999)",
"freezeTime": "Freeze world time",
"clearWeather": "Keep clear weather",
"pvp": "Allow PvP",
"spawnMobs": "Allow natural mob spawning",
"mobGriefing": "Allow mob griefing",
"tntExplodes": "Allow TNT explosions",
"keepInventory": "Keep inventory on death",
"immediateRespawn": "Respawn immediately",
"showAdvancementMessages": "Show advancement announcements",
"bookTitle": "Login book title",
"bookAuthor": "Login book author",
"bookHeading": "Login page heading",
"bookAction": "Login link text",
"bookHelp": "Login instructions",
"openBook": "Open the login book automatically",
"loginTimeoutSeconds": "Login timeout (30–3600 seconds)",
"login_settings_hint": "The system still generates the code and login URL and sends the chat guidance. An explicit login timeout environment variable takes precedence over this setting.",
"lobby_settings_hint": "The welcome message includes a clickable server menu. Spawn comes from the map; use setworldspawn x y z in the lobby console to change it. Protection switches apply across the lobby. Builders keep their own game mode.",
"ADVENTURE": "Adventure",
"SURVIVAL": "Survival",
"CREATIVE": "Creative",
"SPECTATOR": "Spectator",
"PEACEFUL": "Peaceful",
"EASY": "Easy",
"NORMAL": "Normal",
"HARD": "Hard",
"map_title": "Maps & scenes",
"login_map_hint": "Stop the login space, upload your scene as spawn.schem at the root, then start it. Adjust spawn coordinates in server.properties.",
"lobby_map_hint": "Stop the lobby, upload and extract a world ZIP at the root. The world folder must directly contain level.dat. Set level-name in server.properties, then start it. Back up the existing map first.",
"plugins_title": "Plugins & plugin settings",
"plugins_hint": "Upload plugin JARs and edit their configuration. Plugins must match the core and version of this space; bundled Felis plugins are refreshed from the image.",
"files_title": "Advanced file manager",
"files_hint": "Edit server properties, world settings and plugin data. Upload, extract, rename or download files. Authentication forwarding settings are maintained by the system.",
"console_title": "Console & runtime logs",
"console_hint": "Read logs, run lobby commands, set world spawn or manage players.",
"login_console_hint": "Read login runtime logs. Limbo has no RCON command channel; edit configuration in the file manager.",
"backups_title": "Map backups & restore",
"backups_hint": "Back up before replacing maps, plugins or images; choose a saved backup to restore.",
"builder_title": "Lobby builder access",
"builder_hint": "Grant felis.lobby.build to let a player build without opening building to everyone.",
"builder_player": "Minecraft player name",
"builder_grant": "Allow building",
"builder_revoke": "Deny building",
"builder_running": "Start the lobby before changing builder access.",
"discard_title": "Discard unsaved settings?",
"discard_hint": "Switching spaces discards your edits. Cancel to save them first.",
"discard": "Discard & switch"
}
@@ -9,5 +9,6 @@
"admin_images": "Images",
"admin_builds": "Build Pipeline",
"admin_submissions": "Submissions",
"admin_updates": "Maintenance & Backups"
"admin_updates": "Maintenance & Backups",
"admin_lobby": "Login & lobby"
}
+4 -2
View File
@@ -64,7 +64,7 @@
"schedules_link_title": "Scheduled tasks",
"schedules_link_desc": "Restart, back up or run commands at set times.",
"system_service": "System service",
"system_service_hint": "Provisioned and managed by the platform (felis systemservers); it accepts no user operations.",
"system_service_hint": "Staff manage system services in Login & lobby.",
"players_back_to_console": "Back to console",
"players_not_yours_title": "Not your server",
"players_not_yours_body": "Only the owner or an admin can manage this server's players.",
@@ -268,5 +268,7 @@
"migration_state_restoring": "Restoring and verifying target world",
"migration_state_switching": "Switching active volume",
"migration_state_succeeded": "Migration complete; start manually after inspection",
"migration_state_failed": "Migration failed; server remains stopped"
"migration_state_failed": "Migration failed; server remains stopped",
"system_customize": "Customize space",
"edit_system_desc": "Adjust the name, image and resources. A replacement must retain the Felis login or menu plugin and match the proxy protocol. System services do not stop when idle."
}
+85
View File
@@ -0,0 +1,85 @@
{
"title": "登录与大厅",
"subtitle": "设置玩家加入时看到的场景、提示和大厅规则。",
"space_label": "选择要自定义的空间",
"login": "登录空间",
"lobby": "正式大厅",
"login_description": "玩家先在这里验证身份,再进入正式大厅。登录场景使用 Limbo schematic,不能直接使用 Paper 世界地图。",
"lobby_description": "玩家在这里选择服务器。可以更换地图、安装插件、自定义大厅体验并授权建造。",
"connection_version": "安装时的游戏版本:Java {{version}}",
"unknown_version": "未记录",
"version_hint": "客户端版本兼容由代理插件决定。更换版本需配套更新登录空间、大厅镜像和代理;此处显示安装版本,实际版本以镜像为准。",
"images_link": "管理镜像与版本",
"stop_login": "停止登录空间后可读取和保存配置。维护期间新玩家无法进入;完成后请启动登录空间。",
"stop_lobby": "停止大厅后可读取和保存配置。维护前可先备份地图,完成后请启动大厅。",
"read_failed": "读取设置失败:{{reason}}。可在文件管理中检查 felis-experience.json。",
"saved": "已保存。启动该空间后生效。",
"save": "保存设置",
"saving": "正在保存…",
"invalid_values": "请检查数字范围和选项,文案最多 512 个字符。",
"appearance": "欢迎与菜单",
"players": "玩家行为与保护",
"world": "世界环境",
"loginBook": "登录提示与等待",
"menuTitleZh": "中文菜单标题",
"menuTitleEn": "英文菜单标题",
"welcomeZh": "中文欢迎文案",
"welcomeEn": "英文欢迎文案",
"showWelcome": "加入时发送欢迎提示",
"openMenuOnJoin": "加入时自动打开服务器菜单",
"gameMode": "玩家默认游戏模式",
"teleportOnJoin": "加入时回到世界出生点",
"protectBuild": "禁止普通玩家破坏和建造",
"invulnerable": "玩家免受伤害",
"disableHunger": "禁止饥饿",
"voidRescue": "掉入虚空时回到出生点",
"difficulty": "难度",
"time": "固定时间(0–23999)",
"freezeTime": "固定世界时间",
"clearWeather": "保持晴天",
"pvp": "允许 PvP",
"spawnMobs": "允许自然生成生物",
"mobGriefing": "允许生物破坏",
"tntExplodes": "允许 TNT 爆炸",
"keepInventory": "死亡后保留物品",
"immediateRespawn": "立即重生",
"showAdvancementMessages": "显示进度公告",
"bookTitle": "登录书标题",
"bookAuthor": "登录书作者",
"bookHeading": "登录书页标题",
"bookAction": "登录链接文案",
"bookHelp": "登录说明",
"openBook": "自动打开登录书",
"loginTimeoutSeconds": "登录等待时间(30–3600 秒)",
"login_settings_hint": "绑定码和登录链接始终由系统生成,聊天中的提示仍保留。部署中若显式设置登录超时环境变量,它优先于这里的等待时间。",
"lobby_settings_hint": "欢迎提示会附带可点击的服务器菜单。出生点使用地图保存的位置,可在大厅控制台用 setworldspawn x y z 设置。保护开关影响整个大厅;建造权限玩家保留自己的游戏模式。",
"ADVENTURE": "冒险",
"SURVIVAL": "生存",
"CREATIVE": "创造",
"SPECTATOR": "旁观",
"PEACEFUL": "和平",
"EASY": "简单",
"NORMAL": "普通",
"HARD": "困难",
"map_title": "地图与场景",
"login_map_hint": "停止登录空间,上传自定义场景为根目录的 spawn.schem,再启动。出生位置等设置在 server.properties 中调整。",
"lobby_map_hint": "停止大厅,上传世界 ZIP 并解压到根目录,确保世界目录直接包含 level.dat;在 server.properties 中设置 level-name 后启动。建议先备份现有地图。",
"plugins_title": "插件与插件配置",
"plugins_hint": "上传插件 JAR、编辑插件配置。插件需适配该空间的核心与版本;系统自带的 Felis 插件会随镜像更新。",
"files_title": "高级文件管理",
"files_hint": "编辑服务器属性、世界配置和插件数据,上传、解压、重命名或下载文件。认证转发设置由系统维护。",
"console_title": "控制台与运行日志",
"console_hint": "查看日志,执行大厅命令、设置出生点或管理玩家。",
"login_console_hint": "查看登录空间日志。Limbo 不支持 RCON 命令,请通过文件管理修改配置。",
"backups_title": "地图备份与恢复",
"backups_hint": "更换地图、插件或镜像前保存备份;可选择已有备份恢复。",
"builder_title": "大厅建造权限",
"builder_hint": "授予 felis.lobby.build 后,该玩家可在大厅建造,不必开放所有人的建造权限。",
"builder_player": "Minecraft 角色名",
"builder_grant": "允许建造",
"builder_revoke": "禁止建造",
"builder_running": "请先启动大厅,才能修改建造权限。",
"discard_title": "放弃未保存的设置?",
"discard_hint": "切换空间会丢失本次编辑。可以取消后先保存。",
"discard": "放弃并切换"
}
@@ -9,5 +9,6 @@
"admin_images": "镜像",
"admin_builds": "构建流水线",
"admin_submissions": "审核提交",
"admin_updates": "维护与备份"
"admin_updates": "维护与备份",
"admin_lobby": "登录与大厅"
}
+4 -2
View File
@@ -64,7 +64,7 @@
"schedules_link_title": "计划任务",
"schedules_link_desc": "定时重启、备份或执行命令。",
"system_service": "系统服务",
"system_service_hint": "由平台预置并管理(felis systemservers),不接受用户操作。",
"system_service_hint": "系统服务由管理员在“登录与大厅”中管理。",
"players_back_to_console": "返回控制台",
"players_not_yours_title": "这不是你的服务器",
"players_not_yours_body": "只有所有者或管理员才能管理此服务器的玩家。",
@@ -267,5 +267,7 @@
"migration_state_restoring": "正在恢复并校验目标卷",
"migration_state_switching": "正在切换活动卷",
"migration_state_succeeded": "迁移完成,等待手动启动",
"migration_state_failed": "迁移失败,服务器保持停服"
"migration_state_failed": "迁移失败,服务器保持停服",
"system_customize": "自定义大厅",
"edit_system_desc": "调整名称、镜像与资源。更换镜像需保留 Felis 登录或菜单插件,并保持与代理的版本兼容。系统服务不自动休眠。"
}
+95
View File
@@ -0,0 +1,95 @@
import { api } from "./api";
import type { ApiError } from "./types";
export const EXPERIENCE_PATH = "felis-experience.json";
export type Experience = Record<string, unknown>;
export type ExperienceField = {
key: string;
value: string | number | boolean;
choices?: string[];
min?: number;
max?: number;
multiline?: boolean;
};
export type ExperienceGroup = { key: string; fields: ExperienceField[] };
export const LOBBY_GROUPS: ExperienceGroup[] = [
{ key: "appearance", fields: [
{ key: "menuTitleZh", value: "Felis 服务器" },
{ key: "menuTitleEn", value: "Felis Servers" },
{ key: "welcomeZh", value: "欢迎来到大厅。输入 /menu 或点击 " },
{ key: "welcomeEn", value: "Welcome to the lobby. Type /menu or click " },
{ key: "showWelcome", value: true },
{ key: "openMenuOnJoin", value: false },
] },
{ key: "players", fields: [
{ key: "gameMode", value: "ADVENTURE", choices: ["ADVENTURE", "SURVIVAL", "CREATIVE", "SPECTATOR"] },
{ key: "teleportOnJoin", value: true },
{ key: "protectBuild", value: true },
{ key: "invulnerable", value: true },
{ key: "disableHunger", value: true },
{ key: "voidRescue", value: true },
] },
{ key: "world", fields: [
{ key: "difficulty", value: "PEACEFUL", choices: ["PEACEFUL", "EASY", "NORMAL", "HARD"] },
{ key: "time", value: 6000, min: 0, max: 23999 },
{ key: "freezeTime", value: true },
{ key: "clearWeather", value: true },
{ key: "pvp", value: false },
{ key: "spawnMobs", value: false },
{ key: "mobGriefing", value: false },
{ key: "tntExplodes", value: false },
{ key: "keepInventory", value: true },
{ key: "immediateRespawn", value: true },
{ key: "showAdvancementMessages", value: false },
] },
];
export const LOGIN_GROUPS: ExperienceGroup[] = [
{ key: "loginBook", fields: [
{ key: "bookTitle", value: "Felis Login" },
{ key: "bookAuthor", value: "Felis" },
{ key: "bookHeading", value: "Felis 登录 / Login" },
{ key: "bookAction", value: "▶ 点此打开登录页\n▶ Open login page", multiline: true },
{ key: "bookHelp", value: "在系统浏览器中完成。\nUse your SYSTEM browser —\nnot WeChat / QQ (passkey\nwon't work there).", multiline: true },
{ key: "openBook", value: true },
{ key: "loginTimeoutSeconds", value: 600, min: 30, max: 3600 },
] },
];
export function experienceValid(values: Experience, groups: ExperienceGroup[]): boolean {
return groups.flatMap((g) => g.fields).every((field) => {
const value = values[field.key] ?? field.value;
if (typeof value !== typeof field.value) return false;
if (typeof value === "number") return Number.isInteger(value) && value >= field.min! && value <= field.max!;
if (typeof value === "string") return value.length <= 512 && (!field.choices || field.choices.includes(value));
return true;
});
}
export async function readExperience(name: string, groups: ExperienceGroup[]): Promise<{ values: Experience; sha256: string }> {
let file;
try {
file = await api.readServerFile(name, EXPERIENCE_PATH);
} catch (error) {
if ((error as ApiError)?.code === "not_found") return { values: {}, sha256: "" };
throw error;
}
const bytes = Uint8Array.from(atob(file.content), (char) => char.charCodeAt(0));
const values: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
if (!values || typeof values !== "object" || Array.isArray(values) || !experienceValid(values as Experience, groups)) {
throw new Error("invalid_experience");
}
return { values: values as Experience, sha256: file.sha256 };
}
export async function writeExperience(name: string, values: Experience, sha256: string): Promise<string> {
const bytes = new TextEncoder().encode(JSON.stringify(values, null, 2) + "\n");
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
const content = btoa(binary);
const file = sha256
? await api.writeServerFile(name, EXPERIENCE_PATH, content, sha256)
: await api.createServerFile(name, EXPERIENCE_PATH, content);
return file.sha256;
}
+2
View File
@@ -1,4 +1,5 @@
import {
DoorOpen,
LayoutDashboard,
Server,
UserRound,
@@ -61,6 +62,7 @@ export const NAV_SECTIONS: NavSection[] = [
adminOnly: true,
ownerOnly: false,
items: [
{ to: "/admin/lobby", key: "admin_lobby", icon: DoorOpen },
{ to: "/admin/images", key: "admin_images", icon: Boxes },
{ to: "/admin/builds", key: "admin_builds", icon: Cpu },
{ to: "/admin/submissions", key: "admin_submissions", icon: ClipboardCheck },
+7 -4
View File
@@ -1378,7 +1378,7 @@ export interface paths {
put?: never;
/**
* Start downloading one backup (owner-or-admin plus a former-owner match).
* @description Starts a Job that reads the archive from the backup store and hands it to felis-api, which streams it to the browser (poll GET /exports/{ticket}, then open its download). The Job checks the archive against the sha256 recorded when it was written as it streams; a mismatch cuts the download off short of its end. On the way out config/paper-global.yml (the cluster's forwarding secret) is left out and server.properties has its rcon.password redacted, so the download carries no Content-Length. A user gets 404 for a backup outside their scope, as their list never shows it. One export per user at a time, 2 across the install, 6 per user per hour.
* @description Starts a Job that reads the archive from the backup store and hands it to felis-api, which streams it to the browser (poll GET /exports/{ticket}, then open its download). The Job checks the archive against the sha256 recorded when it was written as it streams; a mismatch cuts the download off short of its end. On the way out config/paper-global.yml (the cluster's forwarding secret) is left out and server.properties has its rcon.password and forwarding-secrets redacted, so the download carries no Content-Length. A user gets 404 for a backup outside their scope, as their list never shows it. One export per user at a time, 2 across the install, 6 per user per hour.
*/
post: operations["exportBackup"];
delete?: never;
@@ -1566,7 +1566,7 @@ export interface paths {
put?: never;
/**
* Start downloading one file or folder of a stopped server's world (owner-or-admin).
* @description An export (poll GET /exports/{ticket}, then open its download): a Job reads the file, or zips the folder, from the world volume read-only and hands it to felis-api, which streams it to the browser. A file saves under its own name with its length; a folder as NAME.zip, streamed without one, with symbolic links, devices and sockets left out. config/paper-global.yml, the cluster's forwarding secret, is refused as a file and left out of a folder, and server.properties goes out with its rcon.password redacted; both are matched by the file itself, so a link to either under another name is guarded too. The server cannot start until the download has ended. Two file downloads per user at a time, 4 across the install, 30 per user per hour, counted apart from world and backup exports. Audited as file.download.
* @description An export (poll GET /exports/{ticket}, then open its download): a Job reads the file, or zips the folder, from the world volume read-only and hands it to felis-api, which streams it to the browser. A file saves under its own name with its length; a folder as NAME.zip, streamed without one, with symbolic links, devices and sockets left out. config/paper-global.yml, the cluster's forwarding secret, is refused as a file and left out of a folder, and server.properties goes out with its rcon.password and forwarding-secrets redacted; both are matched by the file itself, so a link to either under another name is guarded too. The server cannot start until the download has ended. Two file downloads per user at a time, 4 across the install, 30 per user per hour, counted apart from world and backup exports. Audited as file.download.
*/
post: operations["downloadServerFile"];
delete?: never;
@@ -3035,7 +3035,7 @@ export interface components {
claimable: boolean;
/** @description Present and true when the owner lookup failed, so an absent owner says nothing about whether the server is claimed. */
ownerUnknown?: boolean;
/** @description True for a platform-provisioned system service (the login gate, the lobby). Their reserved names are rejected by every per-server route, so the cockpit renders them read-only instead of offering actions that would 400. */
/** @description True for a platform-provisioned system service (the login gate, the lobby). Staff can manage them through the existing server routes; players see them read-only. Creating, claiming and deleting these reserved names remain prohibited. */
system?: boolean;
};
/** @description A pending retirement (internal/api/repo.go RetireState): the owner gave the server up, or with delete an admin is deleting it. The reaper carries it out on its next daily run: it archives the world as a released backup, deletes the world volume and releases the server, and for a deletion also removes it. Until then the server stays stopped and cannot be woken or claimed. */
@@ -4921,6 +4921,7 @@ export interface operations {
};
};
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
@@ -4950,6 +4951,7 @@ export interface operations {
};
};
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
/** @description Quota exceeded. */
403: {
@@ -5627,6 +5629,7 @@ export interface operations {
"application/json": components["schemas"]["ServerInfo"];
};
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
@@ -7256,7 +7259,7 @@ export interface operations {
* @description Base64-encoded file bytes.
*/
content: string;
/** @description SHA-256 of the file as stored (before the rcon.password redaction in server.properties). Send it back as expect_sha256 on the next write. */
/** @description SHA-256 of the file as stored (before secret redaction in server.properties). Send it back as expect_sha256 on the next write. */
sha256: string;
/** @description SHA-256 of the decoded content as sent (after any redaction). A client that gets content hashing otherwise got it damaged on the way, and reads it again. */
content_sha256: string;
+1
View File
@@ -340,6 +340,7 @@ export function ServerConsole() {
{isAdmin && (
<EditServerDialog
serverName={name}
systemService={name === "login" || name === "lobby"}
currentDisplayName={data.displayName}
currentPolicy={data.autostartPolicy as AutostartPolicy}
currentImage={data.image}
+3 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { useParams, useSearchParams } from "react-router-dom";
import {
AlertTriangle,
ArrowUp,
@@ -115,6 +115,7 @@ type Naming =
* instead of letting each call fail. */
export function ServerFiles() {
const { name = "" } = useParams();
const [params] = useSearchParams();
const { t, i18n } = useTranslation("files");
const locale = i18n.language;
const { isAdmin, loading: tierLoading } = useTier();
@@ -128,7 +129,7 @@ export function ServerFiles() {
const owned = canManage(isAdmin, mineQ.data, name);
const stopped = statusQ.data?.phase === "Stopped";
const [dir, setDir] = useState("");
const [dir, setDir] = useState(params.get("path") ?? "");
const [entries, setEntries] = useState<ServerFileEntry[] | null>(null);
const [truncated, setTruncated] = useState(false);
const [listErr, setListErr] = useState<unknown>(null);
+83
View File
@@ -0,0 +1,83 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { LobbyPage } from "./LobbyPage";
const calls = vi.hoisted(() => ({
status: vi.fn(), listImages: vi.fn(), readServerFile: vi.fn(), writeServerFile: vi.fn(), createServerFile: vi.fn(), accessPermission: vi.fn(),
}));
vi.mock("@/lib/api", async (original) => ({ ...await original<typeof import("@/lib/api")>(), api: calls }));
vi.mock("@/lib/config", async (original) => ({ ...await original<typeof import("@/lib/config")>(), loadConfig: () => Promise.resolve({ apiBase: "/api/v1", rootDomain: "example.test", gameVersion: "26.3" }) }));
beforeEach(() => {
vi.clearAllMocks();
calls.status.mockImplementation(async (name) => ({ name, subdomain: name, phase: "Stopped", desiredState: "Stopped", ready: false, reaperExempt: true, playersOnline: 0, playersMax: 200 }));
calls.listImages.mockResolvedValue([]);
calls.readServerFile.mockResolvedValue({ content: btoa(JSON.stringify({ menuTitleEn: "Old title", customPlugin: { enabled: true } })), sha256: "read-hash" });
calls.writeServerFile.mockResolvedValue({ sha256: "saved-hash" });
calls.createServerFile.mockResolvedValue({ sha256: "saved-hash" });
});
function page(space = "lobby") {
render(<MemoryRouter initialEntries={[`/admin/lobby?space=${space}`]}><LobbyPage /></MemoryRouter>);
}
describe("LobbyPage", () => {
it("saves with the read hash and preserves unknown plugin settings", async () => {
page();
fireEvent.change(await screen.findByLabelText("English menu title"), { target: { value: "My Network" } });
await userEvent.click(screen.getByRole("button", { name: "Save settings" }));
await screen.findByText("Saved. Start this space to apply the settings.");
const [name, path, content, hash] = calls.writeServerFile.mock.calls[0];
expect([name, path, hash]).toEqual(["lobby", "felis-experience.json", "read-hash"]);
expect(JSON.parse(atob(content))).toEqual({ menuTitleEn: "My Network", customPlugin: { enabled: true } });
});
it("uses defaults for a missing file and creates without overwriting a concurrent file", async () => {
calls.readServerFile.mockRejectedValue({ status: 404, code: "not_found" });
page("login");
fireEvent.change(await screen.findByLabelText("Login timeout (30–3600 seconds)"), { target: { value: "900" } });
await userEvent.click(screen.getByRole("button", { name: "Save settings" }));
await waitFor(() => expect(calls.createServerFile).toHaveBeenCalled());
expect(calls.createServerFile.mock.calls[0].slice(0, 2)).toEqual(["login", "felis-experience.json"]);
expect(calls.writeServerFile).not.toHaveBeenCalled();
});
it("requires a stop before reading or writing a running space", async () => {
calls.status.mockResolvedValue({ name: "lobby", phase: "Running", desiredState: "Running", ready: true });
page();
await screen.findByText(/Stop the lobby to read and save settings/);
expect(calls.readServerFile).not.toHaveBeenCalled();
expect(screen.queryByRole("button", { name: "Save settings" })).toBeNull();
});
it("keeps the draft when another editor changed the file", async () => {
calls.writeServerFile.mockRejectedValue({ status: 409, code: "file_changed" });
page();
fireEvent.change(await screen.findByLabelText("English menu title"), { target: { value: "My Network" } });
await userEvent.click(screen.getByRole("button", { name: "Save settings" }));
await screen.findByRole("alert");
expect((screen.getByLabelText("English menu title") as HTMLInputElement).value).toBe("My Network");
expect(calls.createServerFile).not.toHaveBeenCalled();
});
it("confirms a switch with unsaved edits", async () => {
page();
fireEvent.change(await screen.findByLabelText("English menu title"), { target: { value: "My Network" } });
await userEvent.click(screen.getByRole("button", { name: "Login space" }));
await screen.findByRole("dialog", { name: "Discard unsaved settings?" });
expect(calls.status).not.toHaveBeenCalledWith("login");
await userEvent.click(screen.getByRole("button", { name: "Discard & switch" }));
await screen.findByLabelText("Login book title");
expect(calls.writeServerFile).not.toHaveBeenCalled();
});
it("does not offer to save malformed JSON", async () => {
calls.readServerFile.mockResolvedValue({ content: btoa("{broken"), sha256: "hash" });
page();
await screen.findByRole("alert");
expect(screen.queryByRole("button", { name: "Save settings" })).toBeNull();
});
});
+182
View File
@@ -0,0 +1,182 @@
import { useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { DoorOpen, FolderOpen, Map, Package, Save, Shield, Terminal } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { PageHeader } from "@/components/PageHeader";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PhaseBadge, shownPhase, startFailure } from "@/components/PhaseBadge";
import { PowerButton } from "@/components/PowerButton";
import { CopyAddress } from "@/components/CopyAddress";
import { EditServerDialog } from "@/components/EditServerDialog";
import { ErrorState, Loading } from "@/components/States";
import { InlineError, MessageLine } from "@/components/MessageLine";
import { api, humanizeError } from "@/lib/api";
import { joinAddress } from "@/lib/config";
import { STATUS_POLL_FAST_MS, useAsync, useConfig, usePolling, useUnsavedGuard } from "@/lib/hooks";
import { experienceValid, LOGIN_GROUPS, LOBBY_GROUPS, readExperience, writeExperience, type Experience } from "@/lib/experience";
function ExperienceSettings({ name, stopped, onDirtyChange }: { name: string; stopped: boolean; onDirtyChange: (dirty: boolean) => void }) {
const { t } = useTranslation("lobby");
const groups = name === "login" ? LOGIN_GROUPS : LOBBY_GROUPS;
const query = useAsync(() => stopped ? readExperience(name, groups) : Promise.resolve(null), [name, stopped]);
const [draft, setDraft] = useState<{ values: Experience; original: string; sha256: string } | null>(null);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ kind: "error" | "success"; text: string } | null>(null);
useEffect(() => {
if (query.data && !draft) {
setDraft({ ...query.data, original: JSON.stringify(query.data.values) });
}
}, [query.data, draft]);
const dirty = draft !== null && JSON.stringify(draft.values) !== draft.original;
useUnsavedGuard(dirty);
useEffect(() => onDirtyChange(dirty), [dirty, onDirtyChange]);
async function save() {
if (!draft || !stopped || saving || !experienceValid(draft.values, groups)) return;
setSaving(true);
setMessage(null);
try {
const sha256 = await writeExperience(name, draft.values, draft.sha256);
setDraft({ ...draft, original: JSON.stringify(draft.values), sha256 });
setMessage({ kind: "success", text: t("saved") });
} catch (error) {
setMessage({ kind: "error", text: humanizeError(error) });
} finally {
setSaving(false);
}
}
return (
<div className="space-y-4">
{!stopped && <p className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm">{t(name === "login" ? "stop_login" : "stop_lobby")}</p>}
{stopped && query.loading && !draft && <Loading />}
{query.error != null && <ErrorState error={t("read_failed", { reason: humanizeError(query.error) })} onRetry={query.reload} />}
{draft && groups.map((group) => (
<Card key={group.key}>
<CardHeader><CardTitle>{t(group.key)}</CardTitle></CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
{group.fields.map((field) => {
const value = draft.values[field.key] ?? field.value;
const id = `experience-${field.key}`;
const set = (next: unknown) => setDraft({ ...draft, values: { ...draft.values, [field.key]: next } });
if (typeof field.value === "boolean") return (
<label key={id} className="flex min-h-11 items-center gap-3 rounded-md border border-border px-3 text-sm" htmlFor={id}>
<input id={id} type="checkbox" checked={value as boolean} onChange={(e) => set(e.target.checked)} disabled={!stopped || saving} className="h-4 w-4 accent-primary" />
{t(field.key)}
</label>
);
return (
<div key={id} className="grid min-w-0 gap-2">
<Label htmlFor={id}>{t(field.key)}</Label>
{field.choices ? (
<select id={id} value={value as string} onChange={(e) => set(e.target.value)} disabled={!stopped || saving} className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm">
{field.choices.map((choice) => <option key={choice} value={choice}>{t(choice)}</option>)}
</select>
) : field.multiline ? (
<textarea id={id} value={value as string} maxLength={512} onChange={(e) => set(e.target.value)} disabled={!stopped || saving} className="min-h-24 w-full rounded-md border border-input bg-background p-3 text-sm" />
) : (
<Input id={id} type={typeof field.value === "number" ? "number" : "text"} value={value as string | number} min={field.min} max={field.max} step={1} maxLength={512} onChange={(e) => set(typeof field.value === "number" ? (e.target.value === "" ? "" : Number(e.target.value)) : e.target.value)} disabled={!stopped || saving} />
)}
</div>
);
})}
</CardContent>
</Card>
))}
{draft && <>
<p className="text-sm text-muted-foreground">{t(name === "login" ? "login_settings_hint" : "lobby_settings_hint")}</p>
{message && <MessageLine kind={message.kind} message={message.text} />}
{!experienceValid(draft.values, groups) && <InlineError message={t("invalid_values")} />}
<Button onClick={() => void save()} disabled={!stopped || !dirty || saving || !experienceValid(draft.values, groups)}><Save className="h-4 w-4" />{t(saving ? "saving" : "save")}</Button>
</>}
</div>
);
}
function BuilderAccess({ ready }: { ready: boolean }) {
const { t } = useTranslation("lobby");
const [player, setPlayer] = useState("");
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<{ kind: "error" | "success"; text: string } | null>(null);
async function grant(value: boolean) {
setBusy(true);
setMessage(null);
try {
const result = await api.accessPermission("lobby", "set", player.trim(), "felis.lobby.build", value);
setMessage({ kind: "success", text: result.output });
} catch (error) {
setMessage({ kind: "error", text: humanizeError(error) });
} finally {
setBusy(false);
}
}
return <Card>
<CardHeader><CardTitle>{t("builder_title")}</CardTitle></CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">{t("builder_hint")}</p>
<Label htmlFor="builder-player">{t("builder_player")}</Label>
<Input id="builder-player" value={player} onChange={(e) => setPlayer(e.target.value)} placeholder="Steve" />
<div className="flex flex-wrap gap-2">
<Button disabled={!ready || busy || !/^[A-Za-z0-9_]{1,16}$/.test(player.trim())} onClick={() => void grant(true)}>{t("builder_grant")}</Button>
<Button variant="outline" disabled={!ready || busy || !/^[A-Za-z0-9_]{1,16}$/.test(player.trim())} onClick={() => void grant(false)}>{t("builder_revoke")}</Button>
</div>
{!ready && <p className="text-sm text-muted-foreground">{t("builder_running")}</p>}
{message && <MessageLine kind={message.kind} message={message.text} />}
</CardContent>
</Card>;
}
export function LobbyPage() {
const { t } = useTranslation("lobby");
const [params, setParams] = useSearchParams();
const name = params.get("space") === "login" ? "login" : "lobby";
const [dirty, setDirty] = useState(false);
const [nextSpace, setNextSpace] = useState<string | null>(null);
const cfg = useConfig();
const status = useAsync(() => api.status(name), [name]);
usePolling(status.reload, STATUS_POLL_FAST_MS);
const data = status.data;
const links = [
{ icon: Map, title: "map_title", hint: name === "login" ? "login_map_hint" : "lobby_map_hint", to: `/servers/${name}/files` },
{ icon: Package, title: "plugins_title", hint: "plugins_hint", to: `/servers/${name}/files?path=plugins` },
{ icon: FolderOpen, title: "files_title", hint: "files_hint", to: `/servers/${name}/files` },
{ icon: Terminal, title: "console_title", hint: name === "login" ? "login_console_hint" : "console_hint", to: `/servers/${name}` },
{ icon: Shield, title: "backups_title", hint: "backups_hint", to: `/servers/${name}/backups` },
];
return <div className="space-y-6">
<PageHeader icon={DoorOpen} title={t("title")} subtitle={t("subtitle")} />
<Card><CardContent className="flex flex-wrap items-center justify-between gap-4 pt-5">
<div className="space-y-1">
<p className="text-sm font-medium">{t("connection_version", { version: cfg?.gameVersion ?? t("unknown_version") })}</p>
{cfg && <CopyAddress address={joinAddress("login", cfg)} />}
<p className="max-w-2xl text-xs text-muted-foreground">{t("version_hint")}</p>
</div>
<Link to="/admin/images" className="text-sm text-primary hover:underline">{t("images_link")}</Link>
</CardContent></Card>
<div className="flex flex-wrap gap-2" aria-label={t("space_label")}>
{(["lobby", "login"] as const).map((space) => <Button key={space} variant={name === space ? "default" : "outline"} aria-pressed={name === space} onClick={() => { if (space !== name) { if (dirty) setNextSpace(space); else setParams({ space }); } }}>{t(space)}</Button>)}
</div>
<p className="text-sm text-muted-foreground">{t(`${name}_description`)}</p>
{status.loading && !data && <Loading />}
{status.error != null && <ErrorState error={humanizeError(status.error)} onRetry={status.reload} />}
{data && <>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-card p-4">
<div className="min-w-0 space-y-2"><div className="flex flex-wrap items-center gap-3"><h2 className="font-semibold">{data.displayName || t(name)}</h2><PhaseBadge phase={shownPhase(data)} /></div><p className="break-all text-xs text-muted-foreground">{data.image}</p></div>
<PowerButton name={name} phase={data.phase} desiredState={data.desiredState} failed={startFailure(data) !== null} playersOnline={data.playersOnline} playerCountUnknown={data.playerCountUnknown} onChanged={status.reload} />
</div>
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<ExperienceSettings key={name} name={name} onDirtyChange={setDirty} stopped={data.phase === "Stopped" && data.desiredState === "Stopped"} />
<div className="space-y-4">
{links.map(({ icon: Icon, title, hint, to }) => <Link key={title} to={to} className="flex gap-3 rounded-lg border border-border bg-card p-4 hover:border-primary/40"><Icon className="mt-0.5 h-5 w-5 shrink-0 text-primary" /><div className="min-w-0"><p className="text-sm font-medium">{t(title)}</p><p className="mt-1 text-xs text-muted-foreground">{t(hint)}</p></div></Link>)}
<EditServerDialog serverName={name} systemService currentDisplayName={data.displayName} currentPolicy={data.autostartPolicy} currentImage={data.image} currentMemory={data.memory} currentStorage={data.storageSize} currentCpu={data.cpu} currentIdleStopSeconds={0} onUpdated={status.reload} />
{name === "lobby" && <BuilderAccess ready={data.ready} />}
</div>
</div>
</>}
<ConfirmDialog open={nextSpace !== null} onOpenChange={(open) => { if (!open) setNextSpace(null); }} title={t("discard_title")} description={t("discard_hint")} confirmLabel={t("discard")} onConfirm={async () => { if (nextSpace) { setParams({ space: nextSpace }); setDirty(false); setNextSpace(null); } }} />
</div>;
}
+2 -2
View File
@@ -532,8 +532,8 @@ function ServerActions({
}
if (server.system) {
// A system service carries a reserved name that every per-server route
// rejects, so offer no actions — just the honest label.
// Staff customize system services in the dedicated lobby view.
if (isAdmin) return <Link className="text-sm text-primary hover:underline" to={`/admin/lobby?space=${server.name}`}>{ts("system_customize")}</Link>;
return (
<span className="text-xs text-muted-foreground/70" title={ts("system_service_hint")}>
{ts("system_service")}
@@ -5,6 +5,7 @@ import best.lolicon.felis.link.FelisApiClient;
import best.lolicon.felis.link.LinkClient;
import best.lolicon.felis.link.LinkCode;
import best.lolicon.felis.link.LinkConfig;
import best.lolicon.felis.link.ExperienceConfig;
import best.lolicon.felis.link.LinkConfigLoader;
import com.loohp.limbo.events.EventHandler;
@@ -92,6 +93,7 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
private volatile Readiness readiness;
private volatile LoginFlow flow;
private ExperienceConfig experience;
@Override
public void onEnable() {
@@ -146,6 +148,7 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
try {
File props = new File(getDataFolder(), "felis-link.properties");
cfg = LinkConfigLoader.load(props.toPath());
experience = ExperienceConfig.load(ExperienceConfig.PATH);
} catch (IOException e) {
// Missing/half config: like the other Felis plugins, load un-crippled —
// readiness stays up, the login flow just never turns on.
@@ -161,7 +164,9 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
}
String consoleUrl = "https://" + panelHost;
String lobby = GateConfig.lobby(System.getenv("FELIS_LOBBY_SERVER"));
long timeoutMillis = GateConfig.loginTimeoutSeconds(System.getenv("FELIS_LOGIN_TIMEOUT_SECONDS")) * 1000L;
long timeoutMillis = GateConfig.loginTimeoutSeconds(System.getenv("FELIS_LOGIN_TIMEOUT_SECONDS") != null
? System.getenv("FELIS_LOGIN_TIMEOUT_SECONDS")
: Long.toString(experience.number("loginTimeoutSeconds", GateConfig.DEFAULT_TIMEOUT_SECONDS))) * 1000L;
this.flow = new LoginFlow(new LimboGate(), new FelisApiClient(cfg), new LinkClient(cfg),
consoleUrl, timeoutMillis, System::currentTimeMillis, LOG);
LOG.info("FelisLimbo: login flow ON — console=" + consoleUrl
@@ -187,7 +192,7 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
@Override
public LoginFlow.Seat player(UUID id) {
Player player = getServer().getPlayer(id);
return player != null && player.isValid() ? new LimboSeat(player) : null;
return player != null && player.isValid() ? new LimboSeat(player, experience) : null;
}
@Override
@@ -222,7 +227,7 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
}
/** LimboSeat is one Limbo player as the flow sees them. */
private record LimboSeat(Player player) implements LoginFlow.Seat {
private record LimboSeat(Player player, ExperienceConfig experience) implements LoginFlow.Seat {
@Override
public String name() {
return player.getName();
@@ -235,7 +240,9 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
@Override
public void showCode(LinkCode code, String url) {
player.openBook(LoginBook.book(code.code(), url));
if (experience.flag("openBook", true)) {
player.openBook(LoginBook.book(code.code(), url, experience));
}
}
@Override
@@ -1,5 +1,7 @@
package best.lolicon.felis.limbo;
import best.lolicon.felis.link.ExperienceConfig;
import java.util.Map;
import net.kyori.adventure.inventory.Book;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
@@ -30,15 +32,20 @@ final class LoginBook {
}
static Book book(String code, String url) {
Component page = Component.text("Felis 登录 / Login\n\n")
return book(code, url, new ExperienceConfig(Map.of()));
}
static Book book(String code, String url, ExperienceConfig settings) {
Component page = Component.text(settings.text("bookHeading", "Felis 登录 / Login") + "\n\n")
.append(Component.text("绑定码 / Code:\n"))
.append(Component.text(code + "\n\n").color(NamedTextColor.GOLD))
.append(Component.text("▶ 点此打开登录页\n▶ Open login page\n")
.append(Component.text(settings.text("bookAction", "▶ 点此打开登录页\n▶ Open login page") + "\n")
.color(NamedTextColor.AQUA)
.clickEvent(ClickEvent.openUrl(url)))
.append(Component.text("\n在系统浏览器中完成。\nUse your SYSTEM browser —\nnot WeChat / QQ (passkey\nwon't work there).")
.append(Component.text("\n" + settings.text("bookHelp", "在系统浏览器中完成。\nUse your SYSTEM browser —\nnot WeChat / QQ (passkey\nwon't work there)."))
.color(NamedTextColor.GRAY));
return Book.book(Component.text("Felis Login"), Component.text("Felis"), page);
return Book.book(Component.text(settings.text("bookTitle", "Felis Login")),
Component.text(settings.text("bookAuthor", "Felis")), page);
}
/** chatLine renders one chat line with § colour codes, its web addresses clickable. */
@@ -1,5 +1,6 @@
package best.lolicon.felis.limbo;
import best.lolicon.felis.link.ExperienceConfig;
import best.lolicon.felis.link.Control;
import best.lolicon.felis.link.ControlFrame;
import best.lolicon.felis.link.FelisApiClient;
@@ -24,6 +25,8 @@ import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -356,7 +359,22 @@ public final class LoginFlowTest {
assertEq("lobby set", "hub", GateConfig.lobby(" hub "));
}
private static void loginBook() {
private static void loginBook() throws IOException {
Path file = Files.createTempFile("felis-experience", ".json");
ExperienceConfig settings;
try {
Files.writeString(file, "{\"bookTitle\":\"My Network\",\"bookHeading\":\"欢迎 / Welcome\"}");
settings = ExperienceConfig.load(file);
} finally {
Files.deleteIfExists(file);
}
Book customized = LoginBook.book(CODE, CONSOLE + "/link", settings);
assertEq("custom book title", "My Network", plain(customized.title()));
assertTrue("custom book reads UTF-8 from the world volume", plain(customized.pages().get(0)).contains("欢迎 / Welcome"));
assertEq("custom book keeps the code", true, plain(customized.pages().get(0)).contains(CODE));
List<Component> customLinks = new ArrayList<>();
collectLinks(customized.pages().get(0), customLinks);
assertEq("custom book keeps the generated login URL", CONSOLE + "/link", customLinks.get(0).clickEvent().value());
Book book = LoginBook.book(CODE, CONSOLE + "/link");
assertEq("book title", "Felis Login", plain(book.title()));
Component page = book.pages().get(0);
+3 -1
View File
@@ -41,7 +41,8 @@ dependencies {
// plugin-message channel and never holds a felis-api token or talks to felis-api
// directly. We enforce that physically here — the shared source root is on the
// path, but the include filter ships ONLY the paper package and the three codec
// files (Control + ControlFrame + Json). FelisApiClient, LinkClient and the token
// files (Control + ControlFrame + Json) plus the local ExperienceConfig reader.
// FelisApiClient, LinkClient and the token
// config are not compiled in at all, so the lobby cannot reach the API even by
// mistake. If a codec class grows a new dependency, compilation fails loudly here
// rather than silently widening the lobby's reach.
@@ -53,6 +54,7 @@ sourceSets {
include 'best/lolicon/felis/link/Control.java'
include 'best/lolicon/felis/link/ControlFrame.java'
include 'best/lolicon/felis/link/Json.java'
include 'best/lolicon/felis/link/ExperienceConfig.java'
}
}
}
@@ -2,6 +2,9 @@ package best.lolicon.felis.paper;
import best.lolicon.felis.link.Control;
import best.lolicon.felis.link.ControlFrame;
import best.lolicon.felis.link.ExperienceConfig;
import java.io.IOException;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
@@ -62,10 +65,16 @@ import java.util.List;
public final class FelisPaperPlugin extends JavaPlugin {
private LobbyMenu menu;
private ExperienceConfig experience;
@Override
public void onEnable() {
saveDefaultConfig();
try {
experience = ExperienceConfig.load(ExperienceConfig.PATH);
} catch (IOException e) {
throw new IllegalStateException("Cannot load lobby settings", e);
}
if (!getConfig().getStringList("servers").isEmpty()) {
getLogger().info("config.yml 'servers' is no longer read: the menu lists what the proxy routes.");
}
@@ -81,7 +90,7 @@ public final class FelisPaperPlugin extends JavaPlugin {
// The lobby is a hub nobody can hurt or be hurt in (LobbyGuard). Worlds loaded
// before this point get the rules here, later ones on their WorldLoadEvent.
LobbyGuard guard = new LobbyGuard(getLogger());
LobbyGuard guard = new LobbyGuard(getLogger(), experience);
getServer().getPluginManager().registerEvents(guard, this);
getServer().getWorlds().forEach(guard::protect);
@@ -125,6 +134,11 @@ public final class FelisPaperPlugin extends JavaPlugin {
return FelisPaperPlugin.zh(player);
}
@Override
public String menuTitle() {
return experience.text(zh() ? "menuTitleZh" : "menuTitleEn", zh() ? "Felis 服务器" : "Felis Servers");
}
@Override
public boolean online() {
return player.isOnline();
@@ -1,5 +1,6 @@
package best.lolicon.felis.paper;
import best.lolicon.felis.link.ExperienceConfig;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
@@ -36,6 +37,7 @@ import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.vehicle.VehicleDestroyEvent;
import org.bukkit.event.world.WorldLoadEvent;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -45,6 +47,8 @@ import java.util.logging.Logger;
* Without it the lobby is a plain survival world: mobs at night, PvP, and every block
* broken or placed by a passer-by stays in the world volume for the next player.
*
* <p>The following are defaults; felis-experience.json can adjust each behavior.
*
* <p><b>World.</b> Every world is made peaceful with natural spawning, PvP, mob
* griefing and TNT off, time frozen at noon and the weather clear, and inventories
* kept. These are world rules (level.dat), so they are set here on enable and on
@@ -70,14 +74,20 @@ final class LobbyGuard implements Listener {
/** Noon: the lobby is always lit. */
private static final long NOON = 6000L;
static boolean guarded(Player player) {
return !player.hasPermission(BUILD_PERMISSION);
boolean guarded(Player player) {
return settings.flag("protectBuild", true) && !player.hasPermission(BUILD_PERMISSION);
}
private final Logger log;
private final ExperienceConfig settings;
LobbyGuard(Logger log) {
this(log, new ExperienceConfig(Map.of()));
}
LobbyGuard(Logger log, ExperienceConfig settings) {
this.log = log;
this.settings = settings;
}
/**
@@ -94,25 +104,27 @@ final class LobbyGuard implements Listener {
}
}
static void applyRules(World world) {
world.setDifficulty(Difficulty.PEACEFUL);
void applyRules(World world) {
world.setDifficulty(Difficulty.valueOf(settings.text("difficulty", "PEACEFUL")));
// Only a world with its own clock has a time of day to set; the nether and the
// end have none and refuse.
if (!world.isFixedTime()) {
world.setTime(NOON);
if (settings.flag("freezeTime", true) && !world.isFixedTime()) {
world.setTime(Math.floorMod(settings.number("time", NOON), 24000L));
}
world.setStorm(false);
world.setThundering(false);
world.setGameRule(GameRules.PVP, false);
world.setGameRule(GameRules.SPAWN_MOBS, false);
world.setGameRule(GameRules.SPAWN_WANDERING_TRADERS, false);
world.setGameRule(GameRules.MOB_GRIEFING, false);
world.setGameRule(GameRules.TNT_EXPLODES, false);
world.setGameRule(GameRules.KEEP_INVENTORY, true);
world.setGameRule(GameRules.IMMEDIATE_RESPAWN, true);
world.setGameRule(GameRules.SHOW_ADVANCEMENT_MESSAGES, false);
world.setGameRule(GameRules.ADVANCE_TIME, false);
world.setGameRule(GameRules.ADVANCE_WEATHER, false);
if (settings.flag("clearWeather", true)) {
world.setStorm(false);
world.setThundering(false);
}
world.setGameRule(GameRules.PVP, settings.flag("pvp", false));
world.setGameRule(GameRules.SPAWN_MOBS, settings.flag("spawnMobs", false));
world.setGameRule(GameRules.SPAWN_WANDERING_TRADERS, settings.flag("spawnMobs", false));
world.setGameRule(GameRules.MOB_GRIEFING, settings.flag("mobGriefing", false));
world.setGameRule(GameRules.TNT_EXPLODES, settings.flag("tntExplodes", false));
world.setGameRule(GameRules.KEEP_INVENTORY, settings.flag("keepInventory", true));
world.setGameRule(GameRules.IMMEDIATE_RESPAWN, settings.flag("immediateRespawn", true));
world.setGameRule(GameRules.SHOW_ADVANCEMENT_MESSAGES, settings.flag("showAdvancementMessages", false));
world.setGameRule(GameRules.ADVANCE_TIME, !settings.flag("freezeTime", true));
world.setGameRule(GameRules.ADVANCE_WEATHER, !settings.flag("clearWeather", true));
}
@EventHandler
@@ -125,20 +137,30 @@ final class LobbyGuard implements Listener {
@EventHandler
public void onJoin(PlayerJoinEvent event) {
Player player = event.getPlayer();
player.setFoodLevel(20);
if (guarded(player)) {
player.setGameMode(GameMode.ADVENTURE);
player.teleport(player.getWorld().getSpawnLocation());
if (settings.flag("disableHunger", true)) {
player.setFoodLevel(20);
}
if (!player.hasPermission(BUILD_PERMISSION)) {
player.setGameMode(GameMode.valueOf(settings.text("gameMode", "ADVENTURE")));
if (settings.flag("teleportOnJoin", true)) {
player.teleport(player.getWorld().getSpawnLocation());
}
}
if (settings.flag("showWelcome", true)) {
player.sendMessage(hint(FelisPaperPlugin.zh(player)));
}
if (settings.flag("openMenuOnJoin", false)) {
player.performCommand("menu");
}
player.sendMessage(hint(FelisPaperPlugin.zh(player)));
}
static Component hint(boolean zh) {
Component hint(boolean zh) {
Component open = Component.text(zh ? "[打开服务器菜单]" : "[Open the server menu]",
NamedTextColor.GREEN, TextDecoration.BOLD)
.clickEvent(ClickEvent.runCommand("/menu"))
.hoverEvent(HoverEvent.showText(Component.text(zh ? "点击运行 /menu" : "Click to run /menu")));
return Component.text(zh ? "欢迎来到大厅。输入 /menu 或点击 " : "Welcome to the lobby. Type /menu or click ",
return Component.text(settings.text(zh ? "welcomeZh" : "welcomeEn",
zh ? "欢迎来到大厅。输入 /menu 或点击 " : "Welcome to the lobby. Type /menu or click "),
NamedTextColor.GOLD)
.append(open)
.append(Component.text(zh ? ",选一个服务器进入。" : " to pick a server to join.",
@@ -152,15 +174,17 @@ final class LobbyGuard implements Listener {
if (!(event.getEntity() instanceof Player player)) {
return;
}
event.setCancelled(true);
if (event.getCause() == EntityDamageEvent.DamageCause.VOID) {
if (settings.flag("invulnerable", true)) {
event.setCancelled(true);
}
if (settings.flag("voidRescue", true) && event.getCause() == EntityDamageEvent.DamageCause.VOID) {
player.teleport(player.getWorld().getSpawnLocation());
}
}
@EventHandler(priority = EventPriority.LOW)
public void onHunger(FoodLevelChangeEvent event) {
if (event.getEntity() instanceof Player) {
if (settings.flag("disableHunger", true) && event.getEntity() instanceof Player) {
event.setCancelled(true);
}
}
@@ -211,14 +235,17 @@ final class LobbyGuard implements Listener {
/** Fire spreads and burns with nobody behind it, so only a builder may start one. */
@EventHandler(priority = EventPriority.LOW)
public void onIgnite(BlockIgniteEvent event) {
if (!(event.getIgnitingEntity() instanceof Player player) || guarded(player)) {
if (settings.flag("protectBuild", true) &&
(!(event.getIgnitingEntity() instanceof Player player) || guarded(player))) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.LOW)
public void onBurn(BlockBurnEvent event) {
event.setCancelled(true);
if (settings.flag("protectBuild", true)) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.LOW)
@@ -258,7 +285,7 @@ final class LobbyGuard implements Listener {
}
/** guardedCulprit: the entity is a guarded player, or something one of them shot. */
private static boolean guardedCulprit(Entity culprit) {
private boolean guardedCulprit(Entity culprit) {
if (culprit instanceof Projectile projectile && projectile.getShooter() instanceof Player shooter) {
return guarded(shooter);
}
@@ -42,6 +42,10 @@ final class LobbyMenu implements Listener, PluginMessageListener {
/** zh mirrors the Velocity rule: Chinese when the client locale is zh-*. */
boolean zh();
default String menuTitle() {
return zh() ? "Felis 服务器" : "Felis Servers";
}
boolean online();
void send(ControlFrame frame);
@@ -189,7 +193,7 @@ final class LobbyMenu implements Listener, PluginMessageListener {
slots[NEXT_SLOT] = new Nav(true, zh ? "下一页" : "Next page");
}
}
screen.open(holder, title(zh, p, pages), size, slots);
screen.open(holder, title(screen.menuTitle(), p, pages), size, slots);
// Ask the proxy for live status of every tile on this page; answers repaint them.
for (String server : view) {
screen.send(ControlFrame.statusQuery(server));
@@ -286,7 +290,10 @@ final class LobbyMenu implements Listener, PluginMessageListener {
// ---- text ----
static String title(boolean zh, int page, int pages) {
String title = zh ? "Felis 服务器" : "Felis Servers";
return title(zh ? "Felis 服务器" : "Felis Servers", page, pages);
}
static String title(String title, int page, int pages) {
if (pages > 1) {
title += " (" + (page + 1) + "/" + pages + ")";
}
+4 -1
View File
@@ -4,7 +4,10 @@
# channel to the Velocity proxy. It holds no felis-api token and never contacts
# felis-api directly.
#
# There is nothing to configure. /menu asks the proxy which servers to show
# Player-facing settings are edited in the panel at /admin/lobby and stored in
# /data/felis-experience.json. This file does not hold those settings.
#
# /menu asks the proxy which servers to show
# (a ListRequest over felis:control) and the proxy answers from the same registry
# it routes by, so a server created in the panel appears here on its own. Tiles are
# paged 45 at a time. Status, ownership and autostart stay with the proxy and
@@ -1,6 +1,8 @@
package best.lolicon.felis.paper;
import best.lolicon.felis.link.ExperienceConfig;
import best.lolicon.felis.paper.Fakes.FakePlayer;
import java.util.Map;
import best.lolicon.felis.paper.Fakes.FakeWorld;
import net.kyori.adventure.text.Component;
@@ -82,6 +84,7 @@ public final class LobbyGuardTest {
nobodyIsHurt();
joining();
worldRules();
customization();
System.out.println("LobbyGuardTest OK (" + checks + " checks)");
}
@@ -196,6 +199,33 @@ public final class LobbyGuardTest {
assertEq("... with the same click", List.of("/menu"), commands(zh.messages.get(0)));
}
private static void customization() {
LobbyGuard custom = new LobbyGuard(capturingLogger(), new ExperienceConfig(Map.of(
"protectBuild", false, "invulnerable", false, "disableHunger", false,
"teleportOnJoin", false, "gameMode", "SURVIVAL", "welcomeEn", "Hello, builders! ",
"difficulty", "HARD", "freezeTime", false, "clearWeather", false)));
FakePlayer player = new FakePlayer(false, world, Locale.US);
custom.onJoin(new PlayerJoinEvent(player.player, Component.text("joined")));
assertEq("custom mode", GameMode.SURVIVAL, player.gameMode);
assertEq("custom join does not teleport", 0, player.teleports.size());
assertEq("hunger remains enabled", 3, player.food);
assertEq("custom welcome keeps menu click", List.of("/menu"), commands(player.messages.get(0)));
assertEq("custom welcome", true, Fakes.text(player.messages.get(0)).startsWith("Hello, builders!"));
BlockBreakEvent build = new BlockBreakEvent(Fakes.block(Material.STONE), player.player);
custom.onBreak(build);
assertEq("building can be opened", false, build.isCancelled());
EntityDamageEvent damage = new EntityDamageEvent(player.player, DamageCause.FALL, HIT, 4);
custom.onDamage(damage);
assertEq("damage can be enabled", false, damage.isCancelled());
FoodLevelChangeEvent hunger = new FoodLevelChangeEvent(player.player, 2);
custom.onHunger(hunger);
assertEq("hunger can be enabled", false, hunger.isCancelled());
FakeWorld natural = new FakeWorld();
custom.protect(natural.world);
assertEq("custom difficulty, advancing time and weather", List.of("setDifficulty HARD"), natural.calls);
assertEq("custom menu keeps pagination", "My Hub (2/3)", LobbyMenu.title("My Hub", 1, 3));
}
private static void worldRules() {
FakeWorld overworld = new FakeWorld("world", false, false);
WARNINGS.clear();
@@ -0,0 +1,43 @@
package best.lolicon.felis.link;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
/** Player-facing settings on the persistent world volume, edited by the panel. */
public final class ExperienceConfig {
public static final Path PATH = Path.of("felis-experience.json");
private final Map<?, ?> values;
public ExperienceConfig(Map<?, ?> values) {
this.values = values;
}
public static ExperienceConfig load(Path path) throws IOException {
if (!Files.exists(path)) {
return new ExperienceConfig(Map.of());
}
try {
Object value = Json.parse(Files.readString(path));
if (value instanceof Map<?, ?> map) {
return new ExperienceConfig(map);
}
throw new IllegalArgumentException("expected an object");
} catch (IllegalArgumentException e) {
throw new IOException("invalid " + path + ": " + e.getMessage(), e);
}
}
public String text(String key, String fallback) {
return values.get(key) instanceof String value ? value : fallback;
}
public boolean flag(String key, boolean fallback) {
return values.get(key) instanceof Boolean value ? value : fallback;
}
public long number(String key, long fallback) {
return values.get(key) instanceof Number value ? value.longValue() : fallback;
}
}