From 2c98e8b2ab51216d3176ca0a3f089d3c5dcb6e16 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Sun, 4 Oct 2026 20:18:11 +0800 Subject: [PATCH] perf(panel): speed up file browsing and preserve loading forms --- cmd/felis/api.go | 7 +- cmd/felis/files.go | 11 + docs/openapi.yaml | 50 ++++ docs/troubleshooting.md | 14 +- internal/api/api.go | 3 + internal/api/handlers_files.go | 10 +- internal/api/handlers_files_test.go | 18 ++ internal/fileedit/browser.go | 266 ++++++++++++++++++ internal/fileedit/browser_test.go | 199 +++++++++++++ internal/fileedit/editor.go | 12 +- internal/fileedit/jobspec.go | 21 +- internal/fileedit/k8sjobs.go | 76 +++-- internal/fileedit/k8sjobs_test.go | 67 +++++ panel/e2e/files.smoke.spec.ts | 66 +++++ panel/e2e/smoke.spec.ts | 22 ++ panel/package-lock.json | 159 +++++++++++ panel/package.json | 6 + panel/src/components/files/TextFileEditor.tsx | 89 ++++++ panel/src/i18n/resources/en-US/files.json | 11 +- panel/src/i18n/resources/en-US/lobby.json | 3 +- panel/src/i18n/resources/zh-CN/files.json | 11 +- panel/src/i18n/resources/zh-CN/lobby.json | 3 +- panel/src/lib/openapi.gen.ts | 62 ++++ panel/src/pages/ServerFiles.test.tsx | 58 +++- panel/src/pages/ServerFiles.tsx | 126 +++++++-- panel/src/pages/admin/LobbyPage.test.tsx | 53 +++- panel/src/pages/admin/LobbyPage.tsx | 109 ++++--- 27 files changed, 1387 insertions(+), 145 deletions(-) create mode 100644 internal/fileedit/browser.go create mode 100644 internal/fileedit/browser_test.go create mode 100644 panel/e2e/files.smoke.spec.ts create mode 100644 panel/src/components/files/TextFileEditor.tsx diff --git a/cmd/felis/api.go b/cmd/felis/api.go index 08fac99..dcbfef2 100644 --- a/cmd/felis/api.go +++ b/cmd/felis/api.go @@ -342,11 +342,15 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { // orphaned (the index is in memory), so the stage starts empty. var files api.FileEditor var fileStage *fileedit.Stage + var fileBrowser *fileedit.Browser if felisImage != "" { fcfg := fileEditConfig(cfg, felisImage) fcfg.ResolveWorld = worldResolver + fileBrowser = &fileedit.Browser{BaseURL: internalAPIBaseURL()} + runner := fileedit.NewK8sRunner(clientset) + runner.Browser = fileBrowser files = &fileedit.Editor{ - Runner: fileedit.NewK8sRunner(clientset), + Runner: runner, Config: fcfg, } fileStage = &fileedit.Stage{Dir: fileStagingDir()} @@ -416,6 +420,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { RestoreChains: jobStatus, Files: files, FileStage: fileStage, + FileBrowser: fileBrowser, // The file Job fetches an upload from here; it runs in the minecraft // namespace, where the internal face is reachable like it is for the login // gate. diff --git a/cmd/felis/files.go b/cmd/felis/files.go index 38802e4..71ae58c 100644 --- a/cmd/felis/files.go +++ b/cmd/felis/files.go @@ -40,6 +40,7 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("files", flag.ContinueOnError) fs.SetOutput(stderr) op := fs.String("op", "", "operation: list, read, write, mkdir, delete, rename, upload or unzip") + browseURL := fs.String("browse-url", "", "internal command channel for a read-only file browser") path := fs.String("path", "", "path to operate on, relative to the world root (empty = the root itself)") worldsRoot := fs.String("worlds-root", "/data", "mount path of the world PVC; every path resolves under it") expect := fs.String("expect-sha256", "", "write only: refuse unless the file's current SHA-256 (hex) is this") @@ -53,6 +54,16 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int { return 2 } + if *browseURL != "" { + limitHeapToCgroup() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if err := fileedit.Browse(ctx, *worldsRoot, *browseURL, os.Getenv(fileedit.BrowserTokenEnv)); err != nil { + fmt.Fprintf(stderr, "felis files: %v\n", err) + return 1 + } + return 0 + } if *op == "" { fmt.Fprintln(stderr, "felis files: --op is required") return 2 diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 53ada28..39c4015 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1625,6 +1625,56 @@ paths: '404': $ref: '#/components/responses/NotFound' + /api/v1/internal/file-browser/{id}: + post: + tags: [files] + operationId: internalFileBrowser + summary: Exchange a read-only file Job's result for its next command. + description: >- + Internal face only. A random bearer token scopes the worker to one + world and a four-minute session; idle workers exit after 45 seconds. + The first request sends an empty object. Later requests return the + previous command's id and result or error. This endpoint dispatches + only reads already authorized by the external file API. + x-felis-face: [internal] + x-felis-tier: public + security: [] + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + - { name: Authorization, in: header, required: true, schema: { type: string } } + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + id: { type: string } + result: { type: object } + error: { type: string } + responses: + '200': + description: The next authorized read command. + content: + application/json: + schema: + type: object + required: [id, op, path] + properties: + id: { type: string } + op: { type: string, enum: [list, read] } + path: { type: string } + '204': + $ref: '#/components/responses/NoContent' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '503': + $ref: '#/components/responses/ServiceUnavailable' + /api/v1/internal/exports/{id}: put: tags: [backups] diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5ed7773..5abac48 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -3170,8 +3170,18 @@ for 10 seconds (the Free plan's limits). ## 18. Server files: a change or an upload is refused The panel's Files page is for the server's owner or an admin, and only while -the server is fully stopped. Each call runs a one-shot `felis files` Job in the -`minecraft` namespace, labelled `app.kubernetes.io/managed-by=felis-files` and +the server is fully stopped. Reads reuse a read-only `felis files` Job for the +same world, avoiding Pod startup for every folder and file. The worker pulls +commands from the existing internal API with a random, world-scoped token; +each request still passes the owner/admin and stopped gates. It exits after +45 idle seconds or four minutes total; at most four workers exist per API +process, with one-shot reads used at capacity. No file contents are cached. +The panel caches directory listings for 30 seconds; Refresh, a write, upload +or restore invalidates them. The text editor highlights common config formats +and preserves CRLF; binary and oversized files offer download instead. + +Changes still run one-shot Jobs in the `minecraft` namespace, labelled +`app.kubernetes.io/managed-by=felis-files` and `felis.lolicon.best/files-mode=`. A listing or a read holds nothing. Every change (a save, a new file or folder, a rename, a delete, an upload, an unzip) holds the world for its Job (§3b), so a wake or a diff --git a/internal/api/api.go b/internal/api/api.go index d683304..d81cb64 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -99,6 +99,7 @@ type API struct { // InternalBaseURL is where that Job reaches felis-api's internal face to do so // (handleUploadFile). Uploads report 503 unless both are set. FileStage *fileedit.Stage + FileBrowser *fileedit.Browser InternalBaseURL string // Exporter starts the Job behind a world or backup download (exports.go), @@ -476,6 +477,8 @@ func (a *API) internalAPIRoutes() []apiRoute { // with the upload is the check (handlers_files.go). {Method: "GET", Pattern: "/api/v1/internal/file-uploads/{id}", Public: true, h: a.handleInternalFileUpload}, {Method: "DELETE", Pattern: "/api/v1/internal/file-uploads/{id}", Public: true, h: a.handleInternalFileUploadLanded}, + // Read-only file Jobs pull commands with their own scoped bearer token. + {Method: "POST", Pattern: "/api/v1/internal/file-browser/{id}", Public: true, h: a.handleInternalFileBrowser}, // An export Job's archive, held open until the owner's browser downloads // it. Public for the same reason as file uploads: the Job holds no service diff --git a/internal/api/handlers_files.go b/internal/api/handlers_files.go index 69604b2..e781717 100644 --- a/internal/api/handlers_files.go +++ b/internal/api/handlers_files.go @@ -30,7 +30,7 @@ import ( // internal/fileedit, which explains why that transport needs no RBAC felis-api // does not already hold. Unlike Restorer and Backuper these calls are // SYNCHRONOUS: the caller wants the listing or the bytes, so the handler blocks on -// the Job (seconds, dominated by Pod scheduling) rather than answering 202. +// the result rather than answering 202. // // It is an interface so the handlers are unit-tested against a fake; the // production implementation is *fileedit.Editor. Using fileedit.Entry directly @@ -61,6 +61,14 @@ type FileEditor interface { Ops(ctx context.Context, server string) ([]fileedit.OpState, error) } +func (a *API) handleInternalFileBrowser(w http.ResponseWriter, r *http.Request) { + if a.FileBrowser == nil { + writeError(w, r, newError(http.StatusServiceUnavailable, "files_unavailable", "file browser is not configured")) + return + } + a.FileBrowser.ServeHTTP(w, r) +} + // writeFileRequest is the PUT /servers/{name}/file body. Content is []byte, so // encoding/json requires it to be base64 — which is what makes the write path // binary-safe: a config file with CRLF line endings, a UTF-8 BOM, or a stray diff --git a/internal/api/handlers_files_test.go b/internal/api/handlers_files_test.go index 605265b..3a5b022 100644 --- a/internal/api/handlers_files_test.go +++ b/internal/api/handlers_files_test.go @@ -53,6 +53,24 @@ type fakeFileEditor struct { ops []fileedit.OpState } +func TestFileBrowserStaysOnInternalFaceWithScopedAuthentication(t *testing.T) { + a := newTestAPI(newFakeRepo(), newFakeCluster()) + a.Internal = CallerTokens{CallerVelocity: "service-token"} + a.External = staticExternal{p: &Principal{UserID: "owner", Role: "owner", ViaAdminAccess: true}} + a.FileBrowser = &fileedit.Browser{} + for _, h := range []http.Handler{a.InternalHandler(), a.ExternalHandler()} { + w := do(h, "POST", fileedit.BrowserRoute+"unknown", `{}`, map[string]string{"Authorization": "Bearer service-token"}) + if w.Code != http.StatusNotFound { + t.Fatalf("service token opened a browser: status %d (%s)", w.Code, w.Body.String()) + } + } + a.FileBrowser = nil + w := do(a.InternalHandler(), "POST", fileedit.BrowserRoute+"unknown", `{}`, nil) + if w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "files_unavailable" { + t.Fatalf("unconfigured browser: status %d (%s)", w.Code, w.Body.String()) + } +} + func (f *fakeFileEditor) List(_ context.Context, server, path string) (fileedit.Listing, error) { f.calls++ f.gotServer, f.gotPath = server, path diff --git a/internal/fileedit/browser.go b/internal/fileedit/browser.go new file mode 100644 index 0000000..0ccb939 --- /dev/null +++ b/internal/fileedit/browser.go @@ -0,0 +1,266 @@ +package fileedit + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/subtle" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" +) + +// Browse runs inside the read-only Job and reuses Execute for every command. +// Refuse write commands even if the other end violates the protocol. +func Browse(ctx context.Context, worldRoot, url, token string) error { + if token == "" { + return fmt.Errorf("fileedit: browser token is required") + } + client := &http.Client{Timeout: browserIdle + 30*time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + answer := BrowseAnswer{} + for { + body, err := json.Marshal(answer) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + return err + } + if resp.StatusCode == http.StatusNoContent { + resp.Body.Close() + return nil + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return fmt.Errorf("fileedit: browser exchange returned %s", resp.Status) + } + var command BrowseCommand + err = json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&command) + resp.Body.Close() + if err != nil { + return err + } + if command.Op != OpList && command.Op != OpRead { + return fmt.Errorf("fileedit: browser refused operation %q", command.Op) + } + res, err := Execute(worldRoot, Request{Op: command.Op, Path: command.Path}) + answer = BrowseAnswer{ID: command.ID} + if err != nil { + answer.Error = err.Error() + continue + } + answer.Result, err = json.Marshal(res) + if err != nil { + return err + } + } +} + +const ( + BrowserTokenEnv = "FELIS_FILE_BROWSER_TOKEN" + BrowserRoute = "/api/v1/internal/file-browser/" + browserIdle = 45 * time.Second + browserLifetime = 4 * time.Minute + maxBrowsers = 4 +) + +var errBrowserFull = errors.New("fileedit: browser capacity reached") + +// Browser reuses a short-lived, read-only Job per world. Jobs pull commands from +// the existing internal API face: no inbound Pod port or new RBAC is needed. +// Every browser request is still authorized by the normal file API handlers. +type Browser struct { + BaseURL string + mu sync.Mutex + sessions map[string]*browseSession +} + +type BrowseCommand struct { + ID string `json:"id"` + Op string `json:"op"` + Path string `json:"path"` +} + +type BrowseAnswer struct { + ID string `json:"id"` + Result json.RawMessage `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +type browseSession struct { + id, key string + tokenHash [sha256.Size]byte + commands chan BrowseCommand + answers chan BrowseAnswer + serial chan struct{} + done chan struct{} + once sync.Once + timer *time.Timer + mu sync.Mutex + connected bool + pending string +} + +func (b *Browser) close(s *browseSession) { + b.mu.Lock() + if b.sessions[s.key] == s { + delete(b.sessions, s.key) + } + b.mu.Unlock() + s.once.Do(func() { + if s.timer != nil { + s.timer.Stop() + } + close(s.done) + }) +} + +func (b *Browser) session(ctx context.Context, p JobParams, start func(context.Context, JobParams) error) (*browseSession, error) { + key := strings.Join([]string{p.Namespace, p.Server, p.WorldPVC, p.NodeName, p.Image}, "\x00") + b.mu.Lock() + defer b.mu.Unlock() + if s := b.sessions[key]; s != nil { + return s, nil + } + if len(b.sessions) >= maxBrowsers { + return nil, errBrowserFull + } + token, err := randomHex(32) + if err != nil { + return nil, err + } + s := &browseSession{id: p.OpID, key: key, tokenHash: sha256.Sum256([]byte(token)), + commands: make(chan BrowseCommand, 1), answers: make(chan BrowseAnswer, 1), serial: make(chan struct{}, 1), done: make(chan struct{})} + if b.sessions == nil { + b.sessions = make(map[string]*browseSession) + } + b.sessions[key] = s + p.BrowserURL, p.BrowserToken = strings.TrimRight(b.BaseURL, "/")+BrowserRoute+s.id, token + p.Deadline = browserLifetime + if err := start(ctx, p); err != nil { + delete(b.sessions, key) + return nil, err + } + if err := ctx.Err(); err != nil { + delete(b.sessions, key) + return nil, err + } + s.timer = time.AfterFunc(browserLifetime, func() { b.close(s) }) + return s, nil +} + +func (b *Browser) Run(ctx context.Context, p JobParams, start func(context.Context, JobParams) error) ([]byte, error) { + s, err := b.session(ctx, p, start) + if err != nil { + return nil, err + } + select { + case s.serial <- struct{}{}: + case <-ctx.Done(): + return nil, ctx.Err() + case <-s.done: + return nil, fmt.Errorf("fileedit: browser closed") + } + defer func() { <-s.serial }() + s.mu.Lock() + s.pending = p.OpID + s.mu.Unlock() + select { + case s.commands <- BrowseCommand{ID: p.OpID, Op: p.Op, Path: p.Path}: + case <-ctx.Done(): + b.close(s) + return nil, ctx.Err() + case <-s.done: + return nil, fmt.Errorf("fileedit: browser closed") + } + select { + case answer := <-s.answers: + if answer.Error != "" { + return nil, fmt.Errorf("fileedit: browser read: %s", answer.Error) + } + return answer.Result, nil + case <-ctx.Done(): + b.close(s) + return nil, ctx.Err() + case <-s.done: + return nil, fmt.Errorf("fileedit: browser closed before returning a result") + } +} + +// ServeHTTP accepts one worker's result and long-polls its next command. The +// random token opens only this world/session, and lives only in the Job's env. +func (b *Browser) ServeHTTP(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, BrowserRoute) + b.mu.Lock() + var s *browseSession + for _, candidate := range b.sessions { + if candidate.id == id { + s = candidate + break + } + } + b.mu.Unlock() + token, bearer := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + sum := sha256.Sum256([]byte(token)) + if s == nil || !bearer || subtle.ConstantTimeCompare(sum[:], s.tokenHash[:]) != 1 { + http.Error(w, "unknown browser", http.StatusNotFound) + return + } + var answer BrowseAnswer + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxLogBytes)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&answer); err != nil { + http.Error(w, "invalid result", http.StatusBadRequest) + return + } + s.mu.Lock() + valid := !s.connected && answer.ID == "" || s.connected && answer.ID != "" && answer.ID == s.pending + if valid { + s.connected = true + if answer.ID != "" { + s.pending = "" + } + } + s.mu.Unlock() + if !valid { + http.Error(w, "unexpected result", http.StatusConflict) + return + } + if answer.ID != "" { + select { + case s.answers <- answer: + case <-s.done: + w.WriteHeader(http.StatusGone) + return + } + } + timer := time.NewTimer(browserIdle) + defer timer.Stop() + select { + case command := <-s.commands: + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(command); err != nil { + b.close(s) + } + case <-timer.C: + b.close(s) + w.WriteHeader(http.StatusNoContent) + case <-s.done: + w.WriteHeader(http.StatusNoContent) + case <-r.Context().Done(): + b.close(s) + } +} diff --git a/internal/fileedit/browser_test.go b/internal/fileedit/browser_test.go new file mode 100644 index 0000000..afa2297 --- /dev/null +++ b/internal/fileedit/browser_test.go @@ -0,0 +1,199 @@ +package fileedit + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestBrowserReusesWorkerAndReadsCurrentBytes(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "config.yml"), []byte("enabled: true\n"), 0600); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + b := &Browser{} + srv := httptest.NewServer(b) + b.BaseURL = srv.URL + defer srv.Close() + defer cancel() + var workers atomic.Int32 + start := func(_ context.Context, p JobParams) error { + workers.Add(1) + job, err := FilesJob(p) + if err != nil { + return err + } + pod := job.Spec.Template.Spec + if pod.AutomountServiceAccountToken == nil || *pod.AutomountServiceAccountToken || len(pod.Volumes) != 1 || !pod.Containers[0].VolumeMounts[0].ReadOnly { + t.Error("browser weakened the file Job's isolation") + } + go func() { + if err := Browse(ctx, root, p.BrowserURL, p.BrowserToken); err != nil && ctx.Err() == nil { + t.Errorf("worker: %v", err) + } + }() + return nil + } + read := func(op, path string) Result { + p := testParams(op) + p.Path = path + p.OpID, _ = newOpID() + payload, err := b.Run(ctx, p, start) + if err != nil { + t.Fatal(err) + } + var result Result + if err := json.Unmarshal(payload, &result); err != nil { + t.Fatal(err) + } + return result + } + if got := read(OpRead, "config.yml"); string(got.Content) != "enabled: true\n" { + t.Fatalf("first read: %+v", got) + } + if err := os.WriteFile(filepath.Join(root, "config.yml"), []byte("enabled: false\n"), 0600); err != nil { + t.Fatal(err) + } + if got := read(OpRead, "config.yml"); string(got.Content) != "enabled: false\n" { + t.Fatalf("stale read: %+v", got) + } + if got := read(OpList, ""); len(got.Entries) != 1 { + t.Fatalf("listing: %+v", got) + } + if got := read(OpRead, "../outside"); got.Code != CodeBadPath { + t.Fatalf("containment: %+v", got) + } + if workers.Load() != 1 { + t.Fatalf("started %d workers for repeated reads", workers.Load()) + } + + // Simultaneous readers still receive their own result, never the other path's. + var wg sync.WaitGroup + for _, path := range []string{"config.yml", "missing.yml"} { + wg.Add(1) + go func() { + defer wg.Done() + p := testParams(OpRead) + p.Path = path + p.OpID, _ = newOpID() + payload, err := b.Run(ctx, p, start) + if err != nil { + t.Error(err) + return + } + var got Result + json.Unmarshal(payload, &got) + if path == "missing.yml" && got.Code != CodeNotFound || path == "config.yml" && string(got.Content) != "enabled: false\n" { + t.Errorf("%s: %+v", path, got) + } + }() + } + wg.Wait() +} + +func TestBrowserCancellationRevokesToken(t *testing.T) { + b := &Browser{BaseURL: "http://internal"} + var started JobParams + ctx, cancel := context.WithCancel(context.Background()) + p := testParams(OpRead) + _, err := b.Run(ctx, p, func(_ context.Context, p JobParams) error { started = p; cancel(); return nil }) + if err == nil { + t.Fatal("cancelled read succeeded") + } + r := httptest.NewRequest(http.MethodPost, started.BrowserURL, strings.NewReader(`{}`)) + r.Header.Set("Authorization", "Bearer "+started.BrowserToken) + w := httptest.NewRecorder() + b.ServeHTTP(w, r) + if w.Code != http.StatusNotFound { + t.Fatalf("cancelled token returned %d", w.Code) + } +} + +func TestBrowserRejectsWrongTokenAndStaleResult(t *testing.T) { + b := &Browser{BaseURL: "http://internal"} + p := testParams(OpRead) + var started JobParams + s, err := b.session(context.Background(), p, func(_ context.Context, p JobParams) error { started = p; return nil }) + if err != nil { + t.Fatal(err) + } + defer b.close(s) + for _, header := range []string{"", "Bearer wrong", started.BrowserToken} { + r := httptest.NewRequest(http.MethodPost, started.BrowserURL, strings.NewReader(`{}`)) + r.Header.Set("Authorization", header) + w := httptest.NewRecorder() + b.ServeHTTP(w, r) + if w.Code != http.StatusNotFound { + t.Fatalf("wrong token returned %d", w.Code) + } + } + r := httptest.NewRequest(http.MethodPost, started.BrowserURL, strings.NewReader(`{"id":"another-read","result":{}}`)) + r.Header.Set("Authorization", "Bearer "+started.BrowserToken) + w := httptest.NewRecorder() + b.ServeHTTP(w, r) + if w.Code != http.StatusConflict { + t.Fatalf("stale result returned %d", w.Code) + } +} + +func TestBrowserCapacityReleasedAfterClosingSession(t *testing.T) { + b := &Browser{BaseURL: "http://internal"} + defer func() { + for _, s := range b.sessions { + b.close(s) + } + }() + started := 0 + start := func(context.Context, JobParams) error { started++; return nil } + for i := 0; i < maxBrowsers; i++ { + p := testParams(OpList) + p.Server = string(rune('a' + i)) + p.OpID = p.Server + if _, err := b.session(context.Background(), p, start); err != nil { + t.Fatal(err) + } + } + p := testParams(OpRead) + if _, err := b.session(context.Background(), p, start); err != errBrowserFull || started != maxBrowsers { + t.Fatalf("capacity: err=%v, started=%d", err, started) + } + for _, s := range b.sessions { + b.close(s) + break + } + if _, err := b.session(context.Background(), p, start); err != nil || started != maxBrowsers+1 { + t.Fatalf("reopen: err=%v, started=%d", err, started) + } +} + +func TestBrowseRefusesMutation(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "keep.txt") + os.WriteFile(path, []byte("keep"), 0600) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(BrowseCommand{ID: "x", Op: OpDelete, Path: "keep.txt"}) + })) + defer srv.Close() + if err := Browse(context.Background(), root, srv.URL, "token"); err == nil { + t.Fatal("worker accepted a write command") + } + got, err := os.ReadFile(path) + if err != nil || string(got) != "keep" { + t.Fatalf("file changed: %q, %v", got, err) + } + p := testParams(OpWrite) + p.BrowserURL = srv.URL + p.BrowserToken = "token" + if _, err := FilesJob(p); err == nil { + t.Fatal("writable browser Job was allowed") + } +} diff --git a/internal/fileedit/editor.go b/internal/fileedit/editor.go index 467085f..1e54040 100644 --- a/internal/fileedit/editor.go +++ b/internal/fileedit/editor.go @@ -32,14 +32,14 @@ // upload fetched by the Job from felis-api's internal face (see Stage), because a // 64 MiB jar fits in neither a Job spec nor an environment. // -// The price is latency: every operation is a Pod schedule + image pull, so a -// listing takes seconds rather than milliseconds. That is inherent to RWO plus a -// stopped server, not a property of this transport: the file manager works on a -// stopped server, one operation per Job. +// Interactive reads reuse a bounded, short-lived, read-only Job through Browser. +// Its command channel uses the existing internal API face. At capacity, reads +// use one-shot Jobs and stream results before the Pod's terminal phase. Changes +// still wait for termination to preserve the world lock's ordering. // // The Editor depends on the Runner interface, so the orchestration and the error -// mapping are unit-tested against an in-memory fake; the client-go implementation -// (k8sjobs.go) compiles here but is exercised only against a live cluster. +// mapping are unit-tested against an in-memory fake; the client-go transport +// (k8sjobs.go) is tested against the Kubernetes HTTP API shape. package fileedit import ( diff --git a/internal/fileedit/jobspec.go b/internal/fileedit/jobspec.go index 891e244..ad690d1 100644 --- a/internal/fileedit/jobspec.go +++ b/internal/fileedit/jobspec.go @@ -69,8 +69,10 @@ type JobParams struct { // Async marks a Job felis-api does not wait on: it carries LabelAsync and // AnnotationPath, which Ops reads it back by. Only an upload or an unzip // runs so. - Async bool - WorldPVC string + Async bool + WorldPVC string + BrowserURL string + BrowserToken string Namespace string ServiceAccount string @@ -128,7 +130,7 @@ func filesLabels(p JobParams) map[string]string { // - mounts EXACTLY ONE volume — the world PVC — and NO Secret, NO ConfigMap, and // NO backup PVC. It is therefore strictly blinder than the backup Pod, which // mounts the config Secret to self-record its row: a file-editor Pod has nothing -// to record, so it is handed no database URL and no credential of any kind (the +// to record, so it is handed no database URL or platform credential (the // four-power red line, spec §22); // - mounts that one volume READ-ONLY for list and read (see mutates), so the // two operations that only look physically cannot change anything — the @@ -147,8 +149,12 @@ func filesLabels(p JobParams) map[string]string { // // The container runs `/usr/local/bin/felis files` (cmd/felis), which performs the // operation under os.Root containment and prints the marked JSON Result line that -// felis-api reads back through pods/log. +// felis-api reads back through pods/log. A browser instead pulls read commands +// from the internal API and posts each result there using a scoped token. func FilesJob(p JobParams) (*batchv1.Job, error) { + if p.BrowserURL != "" && (mutates(p.Op) || p.BrowserToken == "" || p.Async) { + return nil, fmt.Errorf("fileedit: a browser must be read-only and have a token") + } if p.Image == "" { return nil, fmt.Errorf("fileedit: image is empty") } @@ -246,7 +252,7 @@ func FilesJob(p JobParams) (*batchv1.Job, error) { // so the spec is the sole channel into the Pod; base64 keeps arbitrary bytes — // CRLF line endings, a UTF-8 BOM, a binary blob — intact through a field that // must be a valid string. Content is set ONLY for a write and the token ONLY - // for an upload, so no other Job spec carries either. + // for an upload or read-only browser, so no other Job spec carries either. switch p.Op { case OpWrite: parts := splitContent(p.Content) @@ -257,6 +263,11 @@ func FilesJob(p JobParams) (*batchv1.Job, error) { case OpUpload: container.Env = []corev1.EnvVar{{Name: UploadTokenEnv, Value: p.UploadToken}} } + if p.BrowserURL != "" { + container.Args = []string{"--browse-url", p.BrowserURL, "--worlds-root", p.WorldsRoot} + container.Env = []corev1.EnvVar{{Name: BrowserTokenEnv, Value: p.BrowserToken}} + container.Resources.Requests = corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("10m"), corev1.ResourceMemory: resource.MustParse("32Mi")} + } job := &batchv1.Job{ ObjectMeta: metav1.ObjectMeta{ diff --git a/internal/fileedit/k8sjobs.go b/internal/fileedit/k8sjobs.go index 949e163..c82ea63 100644 --- a/internal/fileedit/k8sjobs.go +++ b/internal/fileedit/k8sjobs.go @@ -18,12 +18,10 @@ import ( ) // pollInterval is how often the runner re-Lists Pods while waiting for the file -// Job to finish. It is a POLL rather than a Watch because felis-api holds +// container to start. It is a POLL rather than a Watch because felis-api holds // pods:list and NOT pods:watch (internal/platform.APIMinecraftRole) — establishing -// a watch would need a permission this design exists to avoid. Half a second is -// well inside the human-perceptible floor for an operation already dominated by -// Pod scheduling, while keeping the request count on a slow image pull modest. -const pollInterval = 500 * time.Millisecond +// a watch would need a permission this design exists to avoid. +const pollInterval = 150 * time.Millisecond // maxLogBytes bounds what the runner will buffer from a Pod's log. The payload is // at most a base64-encoded MaxReadBytes (≈4/3 of 1 MiB) plus the JSON envelope, so @@ -45,12 +43,11 @@ const maxLogBytes = 4 << 20 // the Job create is available on both — so one client covers all three calls // instead of the binding carrying two. // -// INTEGRATION-ONLY: like K8sLogStreamer and K8sCluster this needs a live cluster; -// it compiles here but is exercised only against one, never by the hermetic test -// suite. The Oracle verifies the layer above it (Editor orchestration and error -// mapping) against a fake Runner, and the Job shape via the pure jobspec. +// The transport is tested against the Kubernetes HTTP API shape; the Editor +// tests separately verify orchestration and error mapping against a fake Runner. type K8sRunner struct { - cs kubernetes.Interface + cs kubernetes.Interface + Browser *Browser } // NewK8sRunner builds a Runner over cs. Every per-operation parameter — the @@ -60,14 +57,23 @@ func NewK8sRunner(cs kubernetes.Interface) *K8sRunner { return &K8sRunner{cs: cs} } -// Run creates the file Job, waits for its Pod to reach a terminal phase, and -// returns the JSON payload from the ResultPrefix line of that Pod's log. +// Run streams read results as soon as they are printed. Mutations still wait +// for termination so returning success does not leave the next write racing +// the cluster-side world lock. // // The Job name carries a fresh random OpID (FilesJobName), so a create collision is // not an expected condition the way it is for restore — an AlreadyExists here means // a 64-bit collision inside one TTL window and is reported rather than absorbed, // because absorbing it would mean returning ANOTHER operation's output. func (k *K8sRunner) Run(ctx context.Context, p JobParams) ([]byte, error) { + if k.Browser != nil && !mutates(p.Op) { + payload, err := k.Browser.Run(ctx, p, k.Start) + // At capacity, retain the bounded one-shot path instead of starting + // more idle workers. All reads still use the same containment checks. + if !errors.Is(err, errBrowserFull) { + return payload, err + } + } job, err := FilesJob(p) if err != nil { return nil, err @@ -81,24 +87,30 @@ func (k *K8sRunner) Run(ctx context.Context, p JobParams) ([]byte, error) { return nil, err } - log, err := k.podLog(ctx, p.Namespace, pod.Name) + stream, err := k.cs.CoreV1().Pods(p.Namespace).GetLogs(pod.Name, &corev1.PodLogOptions{ + Container: containerName, Follow: true, + }).Stream(ctx) if err != nil { - return nil, err + return nil, fmt.Errorf("fileedit: read file job log: %w", err) } - - payload, ok := extractResult(log) - if !ok { - // No marked line: the entrypoint died before printing (an unmountable volume, - // an OOM kill, a deadline). The log tail travels in the error for the operator's - // benefit — this error reaches felis-api's logs, while the caller gets the - // generic 500 writeError produces, so no node detail leaks to the browser. - return nil, fmt.Errorf("fileedit: file job %s produced no result (phase %s): %s", - job.Name, pod.Status.Phase, tail(log)) + defer stream.Close() + sc := bufio.NewScanner(io.LimitReader(stream, maxLogBytes)) + sc.Buffer(make([]byte, 0, 64*1024), maxLogBytes) + var last string + for sc.Scan() { + if payload, ok := strings.CutPrefix(sc.Text(), ResultPrefix); ok { + return []byte(payload), nil + } + last = sc.Text() } - return payload, nil + if err := sc.Err(); err != nil { + return nil, fmt.Errorf("fileedit: read file job result: %w", err) + } + return nil, fmt.Errorf("fileedit: file job %s produced no result (phase %s): %s", + job.Name, pod.Status.Phase, tail(last)) } -// awaitPod polls until the operation's Pod reaches a terminal phase. It selects by +// awaitPod polls until the file container has started (or failed). It selects by // the per-invocation LabelOpID, so it can never observe a different operation's Pod // — the reason that label exists. // @@ -107,7 +119,7 @@ func (k *K8sRunner) Run(ctx context.Context, p JobParams) ([]byte, error) { // is too large) is printed and then exited on cleanly, and even a genuinely failed // Pod may have printed a diagnosable result first. Deciding what the outcome MEANS // is the caller's job (Run reads the printed result); this function only decides -// when there is nothing left to wait for. +// when its log can be opened without a ContainerCreating refusal. func (k *K8sRunner) awaitPod(ctx context.Context, p JobParams) (*corev1.Pod, error) { ticker := time.NewTicker(pollInterval) defer ticker.Stop() @@ -120,9 +132,17 @@ func (k *K8sRunner) awaitPod(ctx context.Context, p JobParams) (*corev1.Pod, err return nil, fmt.Errorf("fileedit: find file job pod: %w", err) } for i := range pods.Items { - switch pods.Items[i].Status.Phase { + pod := &pods.Items[i] + if !mutates(p.Op) { + for _, c := range pod.Status.ContainerStatuses { + if c.Name == containerName && (c.State.Running != nil || c.State.Terminated != nil) { + return pod, nil + } + } + } + switch pod.Status.Phase { case corev1.PodSucceeded, corev1.PodFailed: - return &pods.Items[i], nil + return pod, nil } } diff --git a/internal/fileedit/k8sjobs_test.go b/internal/fileedit/k8sjobs_test.go index de22b6e..a700937 100644 --- a/internal/fileedit/k8sjobs_test.go +++ b/internal/fileedit/k8sjobs_test.go @@ -3,6 +3,10 @@ package fileedit import ( "context" "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" "testing" "time" @@ -10,10 +14,73 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/rest" k8stesting "k8s.io/client-go/testing" ) +func TestRunReadsResultBeforePodTermination(t *testing.T) { + for _, op := range []string{OpList, OpRead} { + t.Run(op, func(t *testing.T) { + closed := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/jobs"): + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"apiVersion":"batch/v1","kind":"Job","metadata":{"name":"files-test"}}`) + case strings.HasSuffix(r.URL.Path, "/pods"): + if !strings.Contains(r.URL.Query().Get("labelSelector"), LabelOpID+"=") { + t.Error("pod lookup did not select this operation") + } + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"apiVersion":"v1","kind":"PodList","items":[{"metadata":{"name":"file-pod"},"status":{"phase":"Running","containerStatuses":[{"name":"files","state":{"running":{}}}]}}]}`) + case strings.HasSuffix(r.URL.Path, "/log"): + if r.URL.Query().Get("follow") != "true" { + t.Error("result log was not streamed") + } + io.WriteString(w, "diagnostic line\n"+ResultPrefix+`{"entries":[]}`+"\n") + w.(http.Flusher).Flush() + // The log stays open and the Pod stays Running. Run must return + // on the result line and close the stream, not await either EOF. + <-r.Context().Done() + close(closed) + default: + t.Errorf("unexpected cluster call: %s %s", r.Method, r.URL) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + cs, err := kubernetes.NewForConfig(&rest.Config{Host: srv.URL}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + got, err := NewK8sRunner(cs).Run(ctx, testParams(op)) + if err != nil || string(got) != `{"entries":[]}` { + t.Fatalf("Run = %s, %v", got, err) + } + select { + case <-closed: + case <-ctx.Done(): + t.Fatal("result stream was not closed") + } + }) + } +} + +func TestAwaitPodKeepsWritesWaitingForTermination(t *testing.T) { + p := testParams(OpWrite) + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "file-pod", Namespace: p.Namespace, Labels: filesLabels(p)}, + Status: corev1.PodStatus{Phase: corev1.PodRunning, ContainerStatuses: []corev1.ContainerStatus{{Name: containerName, State: corev1.ContainerState{Running: &corev1.ContainerStateRunning{}}}}}} + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + if got, err := NewK8sRunner(fake.NewSimpleClientset(pod)).awaitPod(ctx, p); err == nil || got != nil { + t.Fatalf("a running writer was treated as finished: %v, %v", got, err) + } +} + var ( opCreated = time.Date(2026, 9, 28, 10, 0, 0, 0, time.UTC) opEnded = opCreated.Add(3 * time.Minute) diff --git a/panel/e2e/files.smoke.spec.ts b/panel/e2e/files.smoke.spec.ts new file mode 100644 index 0000000..0c5dd3d --- /dev/null +++ b/panel/e2e/files.smoke.spec.ts @@ -0,0 +1,66 @@ +import { test, expect, t, expectFitsScreen } from "./fixtures"; +import { createHash } from "node:crypto"; + +test("file editor highlights configuration, preserves CRLF, undoes edits and saves by shortcut", async ({ page, signIn }) => { + await signIn("owner"); + await page.emulateMedia({ colorScheme: "dark" }); + await page.request.post("/api/v1/servers/lobby/stop"); + const original = "groups:\r\n admin:\r\n enabled: true\r\n permissions:\r\n - limboserver.stop\r\n - limboserver.kick\r\n\r\nplayers:\r\n LOOHP:\r\n - admin\r\n"; + const created = await page.request.put("/api/v1/servers/lobby/file?path=permission.yml", { data: { + content: Buffer.from(original).toString("base64"), content_sha256: createHash("sha256").update(original).digest("hex"), create_only: true, + } }); + expect(created.ok()).toBe(true); + await page.goto("/servers/lobby/files"); + await page.getByRole("button", { name: t("files:open_file", { name: "permission.yml" }), exact: true }).click(); + const editor = page.getByRole("textbox", { name: t("files:file_content") }); + await expect(editor).toBeVisible(); + await expect(page.getByRole("button", { name: t("files:save"), exact: true })).toBeDisabled(); + await expect(page.locator(".cm-lineNumbers .cm-gutterElement")).not.toHaveCount(0); + await expect(page.locator(".cm-line span")).not.toHaveCount(0); + await editor.press("ControlOrMeta+End"); + await page.keyboard.type("# edited"); + await editor.press("ControlOrMeta+z"); + await expect(editor).not.toContainText("# edited"); + await page.keyboard.type("# edited"); + await expectFitsScreen(page); + await page.screenshot({ path: "/tmp/felis-files-editor.png", fullPage: true }); + const saved = page.waitForRequest((r) => r.method() === "PUT" && r.url().includes("/file?path=")); + await editor.press("ControlOrMeta+s"); + const body = (await saved).postDataJSON(); + expect(Buffer.from(body.content, "base64").toString()).toBe(original + "# edited"); + await expect(page.getByRole("dialog")).toHaveCount(0); +}); + +test("binary files show an explanation and download action without requesting text", async ({ page, signIn }) => { + await signIn("owner"); + await page.emulateMedia({ colorScheme: "dark" }); + await page.request.post("/api/v1/servers/lobby/stop"); + let reads = 0; + page.on("request", (r) => { if (r.url().includes("/file?path=")) reads++; }); + await page.goto("/servers/lobby/files"); + await page.getByRole("button", { name: t("files:open_folder", { name: "plugins" }), exact: true }).click(); + await page.getByRole("button", { name: t("files:open_file", { name: "LuckPerms-Bukkit-5.4.141.jar" }), exact: true }).click(); + await expect(page.getByText(t("files:binary_title"), { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: t("files:download_file"), exact: true })).toBeVisible(); + await expect(page.getByRole("textbox")).toHaveCount(0); + expect(reads).toBe(0); + await page.screenshot({ path: "/tmp/felis-files-binary.png", fullPage: true }); +}); + +test("opening a slow file shows immediate progress and closing it ignores the delayed answer", async ({ page, signIn }) => { + await signIn("owner"); + await page.emulateMedia({ colorScheme: "dark" }); + await page.request.post("/api/v1/servers/lobby/stop"); + let release!: () => void; + const pending = new Promise((resolve) => { release = resolve; }); + await page.route("**/api/v1/servers/lobby/file?path=*", async (route) => { await pending; await route.continue(); }); + await page.goto("/servers/lobby/files"); + await page.getByRole("button", { name: t("files:open_file", { name: "server.properties" }), exact: true }).click(); + await expect(page.getByText(t("files:opening_file"), { exact: true })).toBeVisible(); + await page.screenshot({ path: "/tmp/felis-files-loading.png", fullPage: true }); + await page.keyboard.press("Escape"); + const answer = page.waitForResponse((r) => r.url().includes("/file?path=")); + release(); + await answer; + await expect(page.getByRole("dialog")).toHaveCount(0); +}); diff --git a/panel/e2e/smoke.spec.ts b/panel/e2e/smoke.spec.ts index 73dfe93..7e03c83 100644 --- a/panel/e2e/smoke.spec.ts +++ b/panel/e2e/smoke.spec.ts @@ -1,5 +1,27 @@ import { test, expect, t, expectFitsScreen } from "./fixtures"; +test("space settings keep the form visible while reading configuration", async ({ page, signIn }) => { + await signIn("owner"); + await page.emulateMedia({ colorScheme: "dark" }); + let release!: () => void; + const pending = new Promise((resolve) => { release = resolve; }); + await page.route("**/api/v1/servers/login/file?path=felis-experience.json", async (route) => { await pending; await route.continue(); }); + await page.goto("/admin/lobby"); + const title = page.getByLabel(t("lobby:bookTitle")); + await expect(title).toBeVisible(); + await expect(title).toBeDisabled(); + await expect(title).toHaveValue(""); + await expect(page.getByText(t("lobby:loading_settings"), { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: t("lobby:save"), exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: t("lobby:save"), exact: true })).toBeDisabled(); + await expectFitsScreen(page); + await page.screenshot({ path: "/tmp/felis-lobby-loading.png", fullPage: true }); + release(); + await expect(title).toBeEnabled(); + await expect(title).toHaveValue("Felis Login"); + await expect(page.getByText(t("lobby:loading_settings"), { exact: true })).toHaveCount(0); +}); + test("login is the default space; live lobby settings save before a confirmed restart", async ({ page, signIn }) => { await signIn("owner"); await page.goto("/admin/lobby"); diff --git a/panel/package-lock.json b/panel/package-lock.json index 77af0cf..cc8fbc2 100644 --- a/panel/package-lock.json +++ b/panel/package-lock.json @@ -8,12 +8,18 @@ "name": "felis-panel", "version": "0.1.0", "dependencies": { + "@codemirror/language": "^6.12.4", + "@codemirror/legacy-modes": "^6.5.4", + "@codemirror/state": "^6.7.6", + "@codemirror/view": "^6.43.13", + "@lezer/highlight": "^1.2.4", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-slot": "^1.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "codemirror": "^6.0.2", "i18next": "^26.3.3", "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.469.0", @@ -6508,6 +6514,159 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.4.tgz", + "integrity": "sha512-4UeRVFO0lsBaM7GA32zOqbmqNPme9jedo8Ey+5c5656MU3MLitMLIA8aCZTYjUMEpEsuSeXEm78Uea6Iq3bvSg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.4.tgz", + "integrity": "sha512-/cZr6qZyl08iYNLGsJ862CXXNI51LryRFRE40ejgoIjXZz0C1rGkD3/Ek5jM/8w1ceRjqtt4qx/KLMh4zBTgew==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0", + "@lezer/common": "^1.5.0", + "@codemirror/view": "^6.23.0", + "@lezer/highlight": "^1.0.0", + "@codemirror/state": "^6.0.0" + } + }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/lint": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/autocomplete": "^6.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.13", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.13.tgz", + "integrity": "sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==", + "license": "MIT", + "dependencies": { + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4", + "@codemirror/state": "^6.7.0" + } + }, + "node_modules/style-mod": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz", + "integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==", + "license": "MIT" + }, + "node_modules/@lezer/common": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz", + "integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==", + "license": "MIT" + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0", + "@codemirror/view": "^6.17.0", + "@codemirror/state": "^6.0.0", + "@codemirror/language": "^6.0.0" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz", + "integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==", + "license": "MIT", + "dependencies": { + "crelt": "^1.0.5", + "@codemirror/view": "^6.37.0", + "@codemirror/state": "^6.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz", + "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.1.0", + "@codemirror/view": "^6.27.0", + "@codemirror/state": "^6.7.0", + "@codemirror/language": "^6.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "crelt": "^1.0.5", + "@codemirror/view": "^6.42.0", + "@codemirror/state": "^6.0.0" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz", + "integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", + "license": "MIT" + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" } } } diff --git a/panel/package.json b/panel/package.json index e3d5012..de3c6bd 100644 --- a/panel/package.json +++ b/panel/package.json @@ -16,12 +16,18 @@ "preview": "vite preview" }, "dependencies": { + "@codemirror/language": "^6.12.4", + "@codemirror/legacy-modes": "^6.5.4", + "@codemirror/state": "^6.7.6", + "@codemirror/view": "^6.43.13", + "@lezer/highlight": "^1.2.4", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-slot": "^1.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "codemirror": "^6.0.2", "i18next": "^26.3.3", "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.469.0", diff --git a/panel/src/components/files/TextFileEditor.tsx b/panel/src/components/files/TextFileEditor.tsx new file mode 100644 index 0000000..803859c --- /dev/null +++ b/panel/src/components/files/TextFileEditor.tsx @@ -0,0 +1,89 @@ +import { useEffect, useRef } from "react"; +import { basicSetup } from "codemirror"; +import { EditorView, keymap } from "@codemirror/view"; +import { EditorState, Compartment } from "@codemirror/state"; +import { StreamLanguage, syntaxHighlighting, HighlightStyle } from "@codemirror/language"; +import { tags } from "@lezer/highlight"; +import { yaml } from "@codemirror/legacy-modes/mode/yaml"; +import { properties } from "@codemirror/legacy-modes/mode/properties"; +import { toml } from "@codemirror/legacy-modes/mode/toml"; +import { json, javascript } from "@codemirror/legacy-modes/mode/javascript"; +import { xml } from "@codemirror/legacy-modes/mode/xml"; +import { shell } from "@codemirror/legacy-modes/mode/shell"; +import { useTheme } from "@/lib/theme"; + +const languages = { yaml, yml: yaml, properties, toml, json, js: javascript, xml, sh: shell }; + +export function TextFileEditor({ path, value, onChange, onSave, label }: { + path: string; + value: string; + onChange: (value: string) => void; + onSave: () => void; + label: string; +}) { + const host = useRef(null); + const view = useRef(null); + const themeConfig = useRef(new Compartment()); + const callbacks = useRef({ onChange, onSave }); + const { theme } = useTheme(); + useEffect(() => { callbacks.current = { onChange, onSave }; }, [onChange, onSave]); + + useEffect(() => { + const extension = path.split(".").pop()?.toLowerCase() ?? ""; + const language = languages[extension as keyof typeof languages]; + const lineBreak = value.includes("\r\n") ? "\r\n" : value.includes("\r") && !value.includes("\n") ? "\r" : "\n"; + const editor = new EditorView({ + parent: host.current!, + state: EditorState.create({ + doc: value, + extensions: [ + basicSetup, + language ? StreamLanguage.define(language) : [], + EditorState.tabSize.of(2), + EditorState.lineSeparator.of(lineBreak), + EditorView.contentAttributes.of({ "aria-label": label }), + EditorView.updateListener.of((update) => { + if (update.docChanged) callbacks.current.onChange(update.state.sliceDoc()); + }), + keymap.of([{ key: "Mod-s", run: () => { callbacks.current.onSave(); return true; } }]), + themeConfig.current.of(editorTheme(theme === "dark")), + ], + }), + }); + view.current = editor; + editor.focus(); + return () => { editor.destroy(); view.current = null; }; + // A file gets one editor; text and theme changes update it without losing history. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [path, label]); + + useEffect(() => { + const editor = view.current; + if (editor && editor.state.sliceDoc() !== value) { + editor.dispatch({ changes: { from: 0, to: editor.state.doc.length, insert: value } }); + } + }, [value]); + useEffect(() => { + view.current?.dispatch({ effects: themeConfig.current.reconfigure(editorTheme(theme === "dark")) }); + }, [theme]); + + return
; +} + +function editorTheme(dark: boolean) { + return [EditorView.theme({ + "&": { height: "50vh", color: "hsl(var(--foreground))", backgroundColor: "hsl(var(--background))", fontSize: "13px" }, + "&.cm-focused": { outline: "2px solid hsl(var(--ring) / .5)", outlineOffset: "-2px" }, + ".cm-scroller": { fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", lineHeight: "1.7" }, + ".cm-content": { padding: "12px 0", caretColor: "hsl(var(--foreground))" }, + ".cm-gutters": { backgroundColor: "hsl(var(--card))", color: "hsl(var(--muted-foreground))", borderColor: "hsl(var(--border))" }, + ".cm-activeLine, .cm-activeLineGutter": { backgroundColor: "hsl(var(--muted) / .5)" }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground": { backgroundColor: "hsl(var(--primary) / .2)" }, + }, { dark }), syntaxHighlighting(HighlightStyle.define([ + { tag: [tags.keyword, tags.bool, tags.null], color: dark ? "#c4b5fd" : "#7c3aed" }, + { tag: [tags.propertyName, tags.variableName, tags.atom, tags.meta], color: dark ? "#7dd3fc" : "#0369a1" }, + { tag: [tags.string, tags.quote], color: dark ? "#86efac" : "#15803d" }, + { tag: tags.number, color: dark ? "#fdba74" : "#c2410c" }, + { tag: tags.comment, color: dark ? "#94a3b8" : "#64748b", fontStyle: "italic" }, + ]))]; +} diff --git a/panel/src/i18n/resources/en-US/files.json b/panel/src/i18n/resources/en-US/files.json index eb7762a..7b91954 100644 --- a/panel/src/i18n/resources/en-US/files.json +++ b/panel/src/i18n/resources/en-US/files.json @@ -16,7 +16,7 @@ "stopped_required_body": "Files can only be browsed or edited while the server is fully stopped — the world volume is single-attached to the running server. Stop it to continue.", "not_yours_title": "Not your server", "not_yours_body": "Only the owner or an admin can edit this server's files.", - "binary_hint": "Binary file — opened read-only; saving non-text bytes through an editor could corrupt it.", + "binary_hint": "Editing this file as text would damage it. Download it to open with the appropriate application, then upload your changes.", "saved": "Saved {{path}}", "saving": "Saving…", "save": "Save", @@ -129,5 +129,12 @@ "job_failed": "The background task ended without saying why. Refresh the list to check, then try again.", "job_timed_out": "The background task did not finish within 2 hours and was stopped. Refresh the list to check, then try again.", "job_out_of_memory": "The background task ran out of memory and the system stopped it. The files on the server were not changed. Try again.", - "job_out_of_memory_unzip": "The archive holds more files than the extraction task has memory to list, so the system stopped it. The files on the server were not changed. Split it into several smaller zips and extract each one." + "job_out_of_memory_unzip": "The archive holds more files than the extraction task has memory to list, so the system stopped it. The files on the server were not changed. Split it into several smaller zips and extract each one.", + "binary_title": "This is a binary file", + "preview_large_title": "File exceeds the preview limit", + "preview_large_hint": "The editor reads files up to {{limit}}. Download it to edit, then upload your changes.", + "download_file": "Download file", + "opening_file": "Reading file…", + "loading_folder": "Opening {{path}}…", + "file_content": "File contents" } diff --git a/panel/src/i18n/resources/en-US/lobby.json b/panel/src/i18n/resources/en-US/lobby.json index d97dbdd..2c22dc2 100644 --- a/panel/src/i18n/resources/en-US/lobby.json +++ b/panel/src/i18n/resources/en-US/lobby.json @@ -94,5 +94,6 @@ "loginBook_description": "Edit the guidance players see in the login book.", "content_tools": "Content & maintenance", "unsaved": "You have unsaved changes", - "apply_on_start": "Save while running; settings take effect on the next start or restart" + "apply_on_start": "Save while running; settings take effect on the next start or restart", + "loading_settings": "Loading settings. Editing will be available once loaded." } diff --git a/panel/src/i18n/resources/zh-CN/files.json b/panel/src/i18n/resources/zh-CN/files.json index 71999f4..414a825 100644 --- a/panel/src/i18n/resources/zh-CN/files.json +++ b/panel/src/i18n/resources/zh-CN/files.json @@ -16,7 +16,7 @@ "stopped_required_body": "只有在服务器完全停止后才能浏览或编辑文件——世界卷被运行中的服务器独占挂载。请先停止服务器。", "not_yours_title": "这不是你的服务器", "not_yours_body": "只有服务器所有者或管理员才能编辑其文件。", - "binary_hint": "二进制文件——以只读方式打开;通过编辑器保存非文本字节可能损坏该文件。", + "binary_hint": "这是二进制文件,文本编辑会损坏它。请下载后使用对应的软件打开,修改后重新上传。", "saved": "已保存 {{path}}", "saving": "保存中…", "save": "保存", @@ -125,5 +125,12 @@ "job_failed": "后台任务没说明原因就结束了。刷新列表确认一下,再试一次。", "job_timed_out": "后台任务 2 小时还没做完,被停下了。刷新列表确认一下,再试一次。", "job_out_of_memory": "后台任务用完了内存,被系统停掉了。服务器上的文件没有被改动,再试一次。", - "job_out_of_memory_unzip": "压缩包里的文件太多,解压任务的内存装不下它的文件清单,被系统停掉了。服务器上的文件没有被改动。把它拆成几个小一些的 zip,分别上传解压。" + "job_out_of_memory_unzip": "压缩包里的文件太多,解压任务的内存装不下它的文件清单,被系统停掉了。服务器上的文件没有被改动。把它拆成几个小一些的 zip,分别上传解压。", + "binary_title": "此文件不能作为文本编辑", + "preview_large_title": "文件超出在线预览上限", + "preview_large_hint": "在线编辑器最多读取 {{limit}}。请下载后编辑,再重新上传。", + "download_file": "下载文件", + "opening_file": "正在读取文件…", + "loading_folder": "正在打开 {{path}}…", + "file_content": "文件内容" } diff --git a/panel/src/i18n/resources/zh-CN/lobby.json b/panel/src/i18n/resources/zh-CN/lobby.json index 6954a88..af3e5ae 100644 --- a/panel/src/i18n/resources/zh-CN/lobby.json +++ b/panel/src/i18n/resources/zh-CN/lobby.json @@ -94,5 +94,6 @@ "loginBook_description": "编辑玩家打开登录书时看到的引导内容。", "content_tools": "内容与维护", "unsaved": "有未保存的更改", - "apply_on_start": "可以在运行中保存;设置将在下次启动或重启后生效" + "apply_on_start": "可以在运行中保存;设置将在下次启动或重启后生效", + "loading_settings": "正在加载配置,读取完成后即可编辑。" } diff --git a/panel/src/lib/openapi.gen.ts b/panel/src/lib/openapi.gen.ts index de1fa80..c4f1de3 100644 --- a/panel/src/lib/openapi.gen.ts +++ b/panel/src/lib/openapi.gen.ts @@ -232,6 +232,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/internal/file-browser/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Exchange a read-only file Job's result for its next command. + * @description Internal face only. A random bearer token scopes the worker to one world and a four-minute session; idle workers exit after 45 seconds. The first request sends an empty object. Later requests return the previous command's id and result or error. This endpoint dispatches only reads already authorized by the external file API. + */ + post: operations["internalFileBrowser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/internal/exports/{id}": { parameters: { query?: never; @@ -4179,6 +4199,48 @@ export interface operations { 404: components["responses"]["NotFound"]; }; }; + internalFileBrowser: { + parameters: { + query?: never; + header: { + Authorization: string; + }; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + id?: string; + result?: Record; + error?: string; + }; + }; + }; + responses: { + /** @description The next authorized read command. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + /** @enum {string} */ + op: "list" | "read"; + path: string; + }; + }; + }; + 204: components["responses"]["NoContent"]; + 400: components["responses"]["BadRequest"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; internalExportUpload: { parameters: { query?: never; diff --git a/panel/src/pages/ServerFiles.test.tsx b/panel/src/pages/ServerFiles.test.tsx index 3000666..63897bd 100644 --- a/panel/src/pages/ServerFiles.test.tsx +++ b/panel/src/pages/ServerFiles.test.tsx @@ -68,6 +68,12 @@ vi.mock("@/lib/api", async (importOriginal) => { }; }); vi.mock("@/lib/tier", () => ({ useTier: () => ({ isAdmin: true, loading: false }) })); +// Editor interactions are exercised in the browser; page tests focus on reads, +// writes, conflict protection and the dialog lifecycle. +vi.mock("@/components/files/TextFileEditor", () => ({ + TextFileEditor: ({ value, onChange, label }: { value: string; onChange: (v: string) => void; label: string }) => +