diff --git a/agent/internal/files/archive_linux.go b/agent/internal/files/archive_linux.go new file mode 100644 index 000000000..b4cb5295d --- /dev/null +++ b/agent/internal/files/archive_linux.go @@ -0,0 +1,235 @@ +package files + +import ( + "archive/zip" + "context" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "os" + "path" + "slices" + "strings" + "time" +) + +// ZIP downloads are staged before headers are sent, so source errors never +// become successful downloads of incomplete archives. The game volume is the +// agent's writable mount. Only one archive may use it at a time. +const ( + maxArchiveSelection = 256 + maxArchiveEntries = 10000 + maxArchiveBytes int64 = 1 << 30 +) + +var errArchiveLimit = errors.New("selection exceeds archive limits") + +func (h *handler) archive(w http.ResponseWriter, req *http.Request) { + if !h.archiveLock.TryLock() { + http.Error(w, "another archive is in progress", http.StatusConflict) + return + } + defer h.archiveLock.Unlock() + selected, prefix, err := h.archivePaths(req.URL.Query()["path"]) + if err != nil { + h.fail(w, err) + return + } + write := func(dst io.Writer) error { + files := selectionArchive{h: h, zip: zip.NewWriter(dst), ctx: req.Context(), names: make(map[string]bool)} + for _, components := range selected { + if err := files.add(components, strings.Join(components[prefix:], "/")); err != nil { + return err + } + } + return files.zip.Close() + } + if req.Method == http.MethodPost { + dest, parseErr := h.components(req.URL.Query().Get("dest")) + if parseErr != nil { + h.fail(w, parseErr) + return + } + if len(dest) == 0 || !strings.EqualFold(path.Ext(dest[len(dest)-1]), ".zip") { + http.Error(w, "choose a .zip filename", http.StatusBadRequest) + return + } + for _, source := range selected { + if len(dest) >= len(source) && slices.Equal(source, dest[:len(source)]) { + http.Error(w, "archive destination overlaps selection", http.StatusBadRequest) + return + } + } + err = h.createFile(dest, write) + if err != nil { + h.archiveError(w, err) + return + } + w.WriteHeader(http.StatusCreated) + return + } + tmp, err := os.CreateTemp(h.root, ".archive-") + if err != nil { + h.fail(w, err) + return + } + defer func() { _ = tmp.Close() }() + // Unlink now; even an interrupted request or a restarted agent leaves no file. + if err = os.Remove(tmp.Name()); err != nil { + h.fail(w, err) + return + } + if err = write(tmp); err != nil { + h.archiveError(w, err) + return + } + size, err := tmp.Seek(0, io.SeekCurrent) + if err != nil { + h.fail(w, err) + return + } + w.Header().Set("Content-Disposition", "attachment; filename=\"selection.zip\"") + w.Header().Set("Content-Type", "application/zip") + http.ServeContent(w, req, "selection.zip", time.Time{}, io.NewSectionReader(tmp, 0, size)) +} + +func (h *handler) archiveError(w http.ResponseWriter, err error) { + if errors.Is(err, errArchiveLimit) { + http.Error(w, errArchiveLimit.Error(), http.StatusRequestEntityTooLarge) + return + } + h.fail(w, err) +} + +func (h *handler) archivePaths(paths []string) ([][]string, int, error) { + if len(paths) == 0 || len(paths) > maxArchiveSelection { + return nil, 0, errBadPath + } + selected := make([][]string, 0, len(paths)) + names := make(map[string]bool, len(paths)) + for _, raw := range paths { + comps, err := h.components(raw) + if err != nil { + return nil, 0, err + } + if len(comps) == 0 || strings.ContainsAny(raw, "\\:") { + return nil, 0, errBadPath + } + key := strings.Join(comps, "/") + if names[key] { + return nil, 0, errBadPath + } + names[key] = true + selected = append(selected, comps) + } + prefix := parentsOf(selected[0]) + for _, comps := range selected { + for i := 1; i < len(comps); i++ { + if names[strings.Join(comps[:i], "/")] { + return nil, 0, errBadPath + } + } + for len(prefix) > len(comps)-1 || !slices.Equal(prefix, comps[:len(prefix)]) { + prefix = prefix[:len(prefix)-1] + } + } + return selected, len(prefix), nil +} + +type selectionArchive struct { + h *handler + zip *zip.Writer + ctx context.Context + names map[string]bool + size int64 +} + +func (a *selectionArchive) add(components []string, name string) error { + if err := a.ctx.Err(); err != nil { + return err + } + // Directory entries can move during traversal. Never publish two members + // with the same archive name, even if the filesystem reports one twice. + if a.names[name] { + return errBadPath + } + if len(a.names) >= maxArchiveEntries || len(components) > 128 { + return errArchiveLimit + } + a.names[name] = true + entry, err := a.h.openFile(components) + if err != nil { + return err + } + defer func() { _ = entry.Close() }() + info, err := entry.Stat() + if err != nil { + return fmt.Errorf("stat archive entry: %w", err) + } + if info.IsDir() { + return a.addFolder(components, name, entry, info) + } + if !info.Mode().IsRegular() { + return errBadPath + } + if info.Size() > maxArchiveBytes-a.size { + return errArchiveLimit + } + a.size += info.Size() + header := &zip.FileHeader{Name: name, Method: zip.Deflate, Modified: info.ModTime()} + dst, err := a.zip.CreateHeader(header) + if err != nil { + return fmt.Errorf("create archive entry: %w", err) + } + // Limit reads to the observed size, including for files that grow while being archived. + n, err := io.Copy(dst, io.LimitReader(archiveReader{a.ctx, entry}, info.Size())) + if err != nil { + return fmt.Errorf("copy archive entry: %w", err) + } + if n != info.Size() { + return io.ErrUnexpectedEOF + } + return nil +} + +func (a *selectionArchive) addFolder(components []string, name string, folder *os.File, info fs.FileInfo) error { + header := &zip.FileHeader{Name: name + "/", Modified: info.ModTime()} + header.SetMode(fs.ModeDir | 0o755) + if _, err := a.zip.CreateHeader(header); err != nil { + return fmt.Errorf("create archive directory: %w", err) + } + for { + names, err := folder.Readdirnames(128) + if err != nil && !errors.Is(err, io.EOF) { + return fmt.Errorf("list archive directory: %w", err) + } + for _, child := range names { + if strings.HasPrefix(child, ".") { + return errDotfile + } + if strings.ContainsAny(child, "\\:") { + return errBadPath + } + if err := a.add(append(slices.Clone(components), child), name+"/"+child); err != nil { + return err + } + } + if errors.Is(err, io.EOF) { + return nil + } + } +} + +type archiveReader struct { + ctx context.Context + src io.Reader +} + +func (r archiveReader) Read(p []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.src.Read(p) +} diff --git a/agent/internal/files/archive_test.go b/agent/internal/files/archive_test.go new file mode 100644 index 000000000..6cf6ffa03 --- /dev/null +++ b/agent/internal/files/archive_test.go @@ -0,0 +1,238 @@ +package files + +import ( + "archive/zip" + "bytes" + "context" + "crypto/rand" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/sys/unix" +) + +func TestFileSelectionArchive(t *testing.T) { + address, root := newServer(t) + for _, folder := range []string{"first", "second"} { + if err := os.Mkdir(filepath.Join(root, folder), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, folder, "config"), []byte(folder), 0o600); err != nil { + t.Fatal(err) + } + } + paths := url.Values{"path": {"/first/config", "/second/config"}} + response := get(t, address, "/files/archive", paths) + payload, err := io.ReadAll(response.Body) + response.Body.Close() + if err != nil || response.StatusCode != http.StatusOK { + t.Fatalf("download: status %d, err %v", response.StatusCode, err) + } + archive, err := zip.NewReader(bytes.NewReader(payload), int64(len(payload))) + if err != nil { + t.Fatal(err) + } + if len(archive.File) != 2 || archive.File[0].Name != "first/config" || archive.File[1].Name != "second/config" { + t.Fatalf("missing folder structure: %v", archive.File) + } + paths.Set("dest", "/selection.zip") + for _, want := range []int{http.StatusCreated, http.StatusConflict} { + response, err = testPost(t, address+"/files/archive?"+paths.Encode(), "application/octet-stream", nil) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != want { + t.Fatalf("save archive: got %d want %d", response.StatusCode, want) + } + } + for _, item := range []struct { + paths []string + status int + }{ + {[]string{"/no-such-file"}, 404}, {[]string{"/first", "/first/config"}, 400}, {[]string{"/first", "/first"}, 400}, + {[]string{"/.state"}, 400}, {[]string{"/"}, 400}, + } { + res := get(t, address, "/files/archive", url.Values{"path": item.paths}) + res.Body.Close() + if res.StatusCode != item.status { + t.Errorf("%v: status %d want %d", item.paths, res.StatusCode, item.status) + } + } +} + +func TestArchiveDoesNotPublishRefusedSelection(t *testing.T) { + address, root := newServer(t) + folder := filepath.Join(root, "folder") + if err := os.Mkdir(folder, 0o700); err != nil { + t.Fatal(err) + } + for _, item := range []struct { + name string + want int + populate func(string) error + }{ + {"hidden", 400, func(p string) error { return os.WriteFile(filepath.Join(p, ".private"), []byte("secret"), 0o600) }}, + {"symlink", 400, func(p string) error { return os.Symlink(t.TempDir(), filepath.Join(p, "link")) }}, + {"large", 413, func(p string) error { + name := filepath.Join(p, "large") + if err := os.WriteFile(name, nil, 0o600); err != nil { + return err + } + return os.Truncate(name, maxArchiveBytes+1) + }}, + } { + t.Run(item.name, func(t *testing.T) { + child := filepath.Join(folder, item.name) + if err := os.Mkdir(child, 0o700); err != nil { + t.Fatal(err) + } + if err := item.populate(child); err != nil { + t.Fatal(err) + } + q := url.Values{"path": {"/folder/" + item.name}, "dest": {"/result.zip"}} + getResult := get(t, address, "/files/archive", q) + getResult.Body.Close() + post, err := testPost(t, address+"/files/archive?"+q.Encode(), "application/octet-stream", nil) + if err != nil { + t.Fatal(err) + } + post.Body.Close() + if post.StatusCode != item.want || getResult.StatusCode != item.want { + t.Errorf("GET %d POST %d want %d", getResult.StatusCode, post.StatusCode, item.want) + } + if _, err := os.Stat(filepath.Join(root, "result.zip")); !os.IsNotExist(err) { + t.Fatalf("published refused archive: %v", err) + } + }) + } +} + +func TestArchiveRequestBoundaries(t *testing.T) { + h := &handler{root: t.TempDir()} + request := func(method string, paths url.Values, want int) { + t.Helper() + rec := httptest.NewRecorder() + h.archive(rec, httptest.NewRequestWithContext(t.Context(), method, "/files/archive?"+paths.Encode(), nil)) + if rec.Code != want { + t.Fatalf("status=%d want=%d body=%s", rec.Code, want, rec.Body.String()) + } + } + request(http.MethodGet, nil, http.StatusBadRequest) + request(http.MethodGet, url.Values{"path": make([]string, maxArchiveSelection+1)}, http.StatusBadRequest) + for _, dest := range []string{"", "/.private/a.zip", "/source.txt", "/source/a.zip"} { + request(http.MethodPost, url.Values{"path": {"/source"}, "dest": {dest}}, http.StatusBadRequest) + } + h.archiveLock.Lock() + request(http.MethodGet, url.Values{"path": {"/source"}}, http.StatusConflict) + h.archiveLock.Unlock() + h.root = filepath.Join(h.root, "missing") + request(http.MethodGet, url.Values{"path": {"/source"}}, http.StatusNotFound) +} + +func TestArchiveFoldersAndSpecialEntries(t *testing.T) { + root := t.TempDir() + folder := filepath.Join(root, "world") + if err := os.Mkdir(folder, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(folder, "one"), []byte("contents"), 0600); err != nil { + t.Fatal(err) + } + h := &handler{root: root} + archive := func(w io.Writer) *selectionArchive { + return &selectionArchive{h: h, zip: zip.NewWriter(w), ctx: t.Context(), names: make(map[string]bool)} + } + var buf bytes.Buffer + a := archive(&buf) + if err := a.add([]string{"world"}, "world"); err != nil { + t.Fatal(err) + } + if err := a.add([]string{"world"}, "world"); !errors.Is(err, errBadPath) { + t.Fatalf("duplicate: %v", err) + } + if err := a.zip.Close(); err != nil { + t.Fatal(err) + } + result, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) + if err != nil || len(result.File) != 2 || result.File[0].Name != "world/" || result.File[1].Name != "world/one" { + t.Fatalf("archive: %+v, %v", result, err) + } + if err = unix.Mkfifo(filepath.Join(root, "pipe"), 0600); err != nil { + t.Fatal(err) + } + if err = archive(io.Discard).add([]string{"pipe"}, "pipe"); !errors.Is(err, errBadPath) { + t.Fatalf("FIFO: %v", err) + } + if err = os.WriteFile(filepath.Join(folder, "bad:name"), nil, 0600); err != nil { + t.Fatal(err) + } + if err = archive(io.Discard).add([]string{"world"}, "world"); !errors.Is(err, errBadPath) { + t.Fatalf("unportable ZIP member: %v", err) + } + if err = archive(io.Discard).add(make([]string, 129), "deep"); !errors.Is(err, errArchiveLimit) { + t.Fatalf("depth: %v", err) + } +} + +type rejectedArchiveOutput struct{} + +func (rejectedArchiveOutput) Write([]byte) (int, error) { return 0, io.ErrClosedPipe } + +func TestArchiveStopsOnCanceledReadsAndWriteErrors(t *testing.T) { + root := t.TempDir() + h := &handler{root: root} + ctx, cancel := context.WithCancel(t.Context()) + cancel() + a := selectionArchive{h: h, zip: zip.NewWriter(io.Discard), ctx: ctx, names: map[string]bool{}} + if err := a.add([]string{"file"}, "file"); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled archive: %v", err) + } + if _, err := (archiveReader{ctx, strings.NewReader("text")}).Read(make([]byte, 4)); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled read: %v", err) + } + a.ctx = t.Context() + data := make([]byte, 1<<18) + if _, err := rand.Read(data); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "large"), data, 0600); err != nil { + t.Fatal(err) + } + a.zip = zip.NewWriter(rejectedArchiveOutput{}) + if err := a.add([]string{"large"}, "large"); !errors.Is(err, io.ErrClosedPipe) { + t.Fatalf("write error: %v", err) + } + for _, path := range []string{"large", ""} { + a.zip = zip.NewWriter(io.Discard) + a.names = map[string]bool{} + var parts []string + if path != "" { + parts = []string{path} + } + if err := a.add(parts, strings.Repeat("a", 1<<16)); err == nil { + t.Fatal("accepted oversized ZIP member name") + } + } + dir, err := os.Open(root) + if err != nil { + t.Fatal(err) + } + info, err := dir.Stat() + if err != nil { + t.Fatal(err) + } + if err = dir.Close(); err != nil { + t.Fatal(err) + } + if err = a.addFolder(nil, "closed", dir, info); err == nil { + t.Fatal("closed directory unexpectedly readable") + } +} diff --git a/agent/internal/files/files.go b/agent/internal/files/files.go index ade67c6c6..e074577cf 100644 --- a/agent/internal/files/files.go +++ b/agent/internal/files/files.go @@ -15,6 +15,7 @@ import ( "os" "path/filepath" "strings" + "sync" "time" "github.com/go-chi/chi/v5" @@ -45,7 +46,8 @@ func hasDotComponent(rel string) bool { } type handler struct { - root string + archiveLock sync.Mutex + root string // beforeOpen is a test seam. When non-nil it is called synchronously // immediately before a path component is opened ("open"), before the @@ -64,6 +66,8 @@ func Mount(r chi.Router, root string) { r.Route("/files", func(r chi.Router) { // Transfers can legitimately outlive the ordinary operation timeout. r.Get("/download", h.download) + r.Get("/archive", h.archive) + r.Post("/archive", h.archive) r.Post("/upload", h.upload) r.Group(func(bounded chi.Router) { bounded.Use(middleware.Timeout(30 * time.Second)) diff --git a/agent/internal/files/timeouts_test.go b/agent/internal/files/timeouts_test.go index f17e9cd68..9e1db6fd0 100644 --- a/agent/internal/files/timeouts_test.go +++ b/agent/internal/files/timeouts_test.go @@ -18,7 +18,7 @@ func TestFileRouteTimeouts(t *testing.T) { t.Run(method+route, func(t *testing.T) { handler := chi.Chain(middlewares...).Handler(http.HandlerFunc(func(_ http.ResponseWriter, req *http.Request) { deadline, bounded := req.Context().Deadline() - transfer := route == "/files/download" || route == "/files/upload" + transfer := route == "/files/download" || route == "/files/upload" || route == "/files/archive" if bounded == transfer { t.Fatalf("bounded=%t, transfer=%t", bounded, transfer) } diff --git a/agent/openapi.yaml b/agent/openapi.yaml index 49b94061e..1be9bff52 100644 --- a/agent/openapi.yaml +++ b/agent/openapi.yaml @@ -164,6 +164,35 @@ paths: responses: "200": { description: File content } "413": { description: File too large — use /files/download } + '/files/archive': + parameters: + - in: query + name: path + required: true + style: form + explode: true + schema: { type: array, items: { type: string }, minItems: 1, maxItems: 256 } + get: + summary: Download selected paths as a ZIP + responses: + '200': { description: Completed ZIP, content: { application/zip: { schema: { type: string, format: binary } } } } + '400': { description: Invalid or protected selection } + '404': { description: A selected path does not exist } + '409': { description: Another archive is running } + '413': { description: Archive limit exceeded } + post: + summary: Create a ZIP on the server + parameters: + - in: query + name: dest + required: true + schema: { type: string } + responses: + '201': { description: Archive created } + '400': { description: Invalid destination or selection } + '404': { description: A selected path does not exist } + '409': { description: Destination exists or another archive is running } + '413': { description: Archive limit exceeded } /files/download: get: summary: Download a file as an attachment diff --git a/agent/specs.md b/agent/specs.md index 40935f565..3693ee66f 100644 --- a/agent/specs.md +++ b/agent/specs.md @@ -23,7 +23,14 @@ The agent is a per-pod HTTP/HTTPS sidecar that runs inside every game pod to exp - **Console & RCON**: Duplex WebSocket forwarding user commands to the game via RCON and echoing responses; supports Valve/Source, Telnet, WebSocket, BattlEye, Satisfactory, and Palworld protocols. - **File I/O**: List, read, download, upload, write, mkdir, delete within the `/data` volume; reject path traversal and any symlink traversal; every operation runs through directory descriptors rooted at the data directory, so it cannot be redirected by a concurrent filesystem change. - File uploads and downloads have no router-level total-duration timeout. The + GET `/files/archive` builds a downloadable ZIP of repeated `path` parameters. + POST with a `dest` creates a new ZIP atomically. Archive names are relative to + the common parent and retain directory structure. The agent refuses protected + entries, symlinks, special files, duplicate or overlapping selections and an + existing destination. One archive can run at a time per file handler, with + up to 256 selected paths, 10,000 entries and 1 GiB of input. + + Uploads, downloads and archives have no router-level total-duration timeout. The remaining file operations retain a 30-second timeout. Authentication, body size limits and path checks apply to transfers as well as short operations. - **Logs**: Tail the game container's log file over WebSocket (text frames per line); supports streaming from start or end. diff --git a/api/cmd/main.go b/api/cmd/main.go index b1bd15b53..649521124 100644 --- a/api/cmd/main.go +++ b/api/cmd/main.go @@ -897,6 +897,7 @@ func isLargeTransferPath(method, path string) bool { return false } return strings.HasSuffix(path, "/files/download") || + strings.HasSuffix(path, "/files/archive") || strings.HasSuffix(path, "/logs/download") || strings.HasSuffix(path, ":capture-file") } @@ -904,6 +905,7 @@ func isLargeTransferPath(method, path string) bool { // Uploads stream their body on req.Context(). URL installs also // wait for the agent's download and extraction, which may exceed 60s. return strings.HasSuffix(path, "/files/upload") || + strings.HasSuffix(path, "/files/archive") || strings.HasSuffix(path, "/files/write") || strings.HasSuffix(path, "/mods/upload") || strings.HasSuffix(path, "/mods/install") diff --git a/api/docs/openapi.yaml b/api/docs/openapi.yaml index 89fb097f3..d2b9ccdb9 100644 --- a/api/docs/openapi.yaml +++ b/api/docs/openapi.yaml @@ -369,9 +369,43 @@ paths: responses: '200': description: OK + '/servers/{name}/files/archive': + parameters: + - { in: path, name: name, required: true, schema: { type: string } } + - in: query + name: path + required: true + style: form + explode: true + schema: { type: array, items: { type: string }, minItems: 1, maxItems: 256 } + get: + summary: Download selected paths as a ZIP + responses: + '200': { description: Completed ZIP, content: { application/zip: { schema: { type: string, format: binary } } } } + '400': { description: Invalid or protected selection } + '404': { description: A selected path does not exist } + '409': { description: Another archive is running } + '413': { description: Archive limit exceeded } + post: + summary: Create a ZIP on the server + parameters: + - in: query + name: dest + required: true + schema: { type: string } + responses: + '201': { description: Archive created } + '400': { description: Invalid destination or selection } + '404': { description: A selected path does not exist } + '409': { description: Destination exists or another archive is running } + '413': { description: Archive limit exceeded } '/servers/{name}/files/download': get: + parameters: + - { in: path, name: name, required: true, schema: { type: string } } + - { in: query, name: expectedUID, schema: { type: string }, description: Require this GameServer UID } responses: + '409': { description: Server identity changed } '200': description: OK '/servers/{name}/files/list': diff --git a/api/internal/gatewayprotocol/duration.go b/api/internal/gatewayprotocol/duration.go index 09501b152..b6131a98a 100644 --- a/api/internal/gatewayprotocol/duration.go +++ b/api/internal/gatewayprotocol/duration.go @@ -6,6 +6,9 @@ import "net/http" // may arrive only after lengthy work. Caller cancellation and gateway lifetime // limits still apply; ordinary operations keep their short timeouts. func LongRunningOperation(method, path string) bool { + if path == "/files/archive" { + return method == http.MethodGet || method == http.MethodPost + } if method == http.MethodGet { return Streaming(path) || path == "/files/download" || path == "/logs/download" } diff --git a/api/internal/gatewayprotocol/protocol.go b/api/internal/gatewayprotocol/protocol.go index 584f2d41d..ab7cf9b47 100644 --- a/api/internal/gatewayprotocol/protocol.go +++ b/api/internal/gatewayprotocol/protocol.go @@ -53,13 +53,15 @@ func Allowed(method, path string) (int64, bool) { switch method { case http.MethodGet: switch path { - case "/console", "/logs/tail", "/logs/download", "/files/list", "/files/read", "/files/download", "/players", "/players/banned", "/players/whitelist", "/status", "/mods": + case "/console", "/logs/tail", "/logs/download", "/files/list", "/files/read", "/files/download", "/files/archive", "/players", "/players/banned", "/players/whitelist", "/status", "/mods": return 0, true } case http.MethodPost: switch path { case "/actions/run": return 16 << 10, true + case "/files/archive": + return 0, true case "/mods/upload": return 512 << 20, true case "/files/write", "/files/create", "/files/upload", "/files/mkdir", "/players/kick", "/players/ban", "/players/unban", "/players/whitelist/add", "/players/whitelist/remove", "/mods/install": diff --git a/api/internal/rbac/middleware_test.go b/api/internal/rbac/middleware_test.go index 68f086ef9..4ec305dd0 100644 --- a/api/internal/rbac/middleware_test.go +++ b/api/internal/rbac/middleware_test.go @@ -618,6 +618,21 @@ func TestMiddleware_StrangerDenied(t *testing.T) { } } +func TestArchiveRejectsUnrelatedUser(t *testing.T) { + h := Middleware(fakeServerFetcher{ + "gameplane-games/alpha": newOwnershipObj("alpha", 42, "alice", ""), + })(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("request reached proxy") })) + for _, method := range []string{http.MethodGet, http.MethodPost} { + req := httptest.NewRequestWithContext(t.Context(), method, "/servers/alpha/files/archive?path=%2Fconfig", nil) + req = req.WithContext(auth.WithUser(req.Context(), &auth.User{ID: 999})) + res := httptest.NewRecorder() + h.ServeHTTP(res, req) + if res.Code != http.StatusForbidden { + t.Errorf("%s = %d", method, res.Code) + } + } +} + func TestMiddleware_UnknownClusterRejected(t *testing.T) { // A namespaced route with an unregistered ?cluster= selector must be // rejected with 400 before the permission check or fallback ever runs. diff --git a/api/internal/ws/archive_route_test.go b/api/internal/ws/archive_route_test.go new file mode 100644 index 000000000..389c62a59 --- /dev/null +++ b/api/internal/ws/archive_route_test.go @@ -0,0 +1,81 @@ +package ws + +import ( + "net/http" + "net/http/httptest" + "slices" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/GameplanePanel/gameplane/api/internal/kube" +) + +func TestArchiveProxyBindsTargetAndPreservesSelection(t *testing.T) { + calls := 0 + agent := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.URL.Path != "/v1/targets/gs-uid/files/archive" || !slices.Equal(r.URL.Query()["path"], []string{"/one/a", "/two/a"}) { + t.Errorf("archive request = %s", r.URL) + } + if r.Header.Get("X-Gameplane-Server-UID") != "" { + t.Error("browser precondition leaked upstream") + } + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("denied")) + })) + defer agent.Close() + k := &kube.Client{} + _ = streamTestRegistry(k) + p := &proxy{k: k, transport: testDirectTransport(agent)} + router := chi.NewRouter() + router.Get("/servers/{name}/files/archive", p.agentHTTP("/files/archive")) + for _, uid := range []string{"gs-uid", "previous"} { + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/servers/alpha/files/archive?path=%2Fone%2Fa&path=%2Ftwo%2Fa", nil) + req.Header.Set("X-Gameplane-Server-UID", uid) + req.Header.Set("Accept", "application/problem+json") + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + if uid == "previous" { + if res.Code != http.StatusConflict || res.Header().Get("Content-Type") != "application/problem+json" { + t.Fatalf("replacement status %d", res.Code) + } + } else if res.Code != http.StatusForbidden || res.Body.String() != "denied" { + t.Fatalf("denial = %d %q", res.Code, res.Body.String()) + } + } + if calls != 1 { + t.Fatalf("upstream calls = %d", calls) + } +} + +func TestFileDownloadPinsBrowserSelection(t *testing.T) { + calls := 0 + agent := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.URL.Path != "/v1/targets/gs-uid/files/download" || r.URL.Query().Get("path") != "/file" || r.URL.Query().Has("expectedUID") { + t.Errorf("download request: %v", r.URL) + } + w.WriteHeader(http.StatusOK) + })) + defer agent.Close() + k := &kube.Client{} + _ = streamTestRegistry(k) + p := &proxy{k: k, transport: testDirectTransport(agent)} + router := chi.NewRouter() + router.Get("/servers/{name}/files/download", p.agentHTTP("/files/download")) + for _, tc := range []struct { + uid string + status int + }{{"old", 409}, {"gs-uid", 200}} { + res := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/servers/alpha/files/download?path=%2Ffile&expectedUID="+tc.uid, nil) + router.ServeHTTP(res, req) + if res.Code != tc.status { + t.Errorf("UID %s returned %d", tc.uid, res.Code) + } + } + if calls != 1 { + t.Fatalf("%d upstream calls", calls) + } +} diff --git a/api/internal/ws/dialer.go b/api/internal/ws/dialer.go index 662593f3a..2d70642c6 100644 --- a/api/internal/ws/dialer.go +++ b/api/internal/ws/dialer.go @@ -65,6 +65,8 @@ func MountWithPlayerNames(r chi.Router, reg *kube.Registry, caBundle, clientCert r.Get("/list", p.agentHTTP("/files/list")) r.Get("/read", p.agentHTTP("/files/read")) r.Get("/download", p.agentHTTP("/files/download")) + r.Get("/archive", p.agentHTTP("/files/archive")) + r.Post("/archive", p.agentHTTP("/files/archive")) r.Post("/write", p.agentHTTP("/files/write")) r.Post("/create", p.agentHTTP("/files/create")) r.Post("/upload", p.agentHTTP("/files/upload")) @@ -240,6 +242,23 @@ func (p *proxy) httpProxyLimit(agentPath string, maxBody int64) http.HandlerFunc return } target := agentTarget{name: name, namespace: ns, uid: p.remoteUID} + if uid := req.Header.Get("X-Gameplane-Server-UID"); uid != "" && uid != target.uid { + httperr.WriteCode(w, req, http.StatusConflict, errors.New("server identity changed")) + return + } + rawQuery := req.URL.RawQuery + if agentPath == "/files/download" { + // An ordinary browser download cannot attach a precondition header. + query := req.URL.Query() + if uid := query.Get("expectedUID"); uid != "" { + if uid != target.uid { + httperr.WriteCode(w, req, http.StatusConflict, errors.New("server identity changed")) + return + } + query.Del("expectedUID") + rawQuery = query.Encode() + } + } // Validate before opening an upstream or upgrading the browser connection. if err := target.validate(); err != nil { httperr.WriteCode(w, req, http.StatusBadRequest, err) @@ -252,7 +271,7 @@ func (p *proxy) httpProxyLimit(agentPath string, maxBody int64) http.HandlerFunc // compromised sidecar would hand over live session tokens. resp, err := p.transport.Do(req.Context(), agentRequest{ target: target, method: req.Method, path: agentPath, - rawQuery: req.URL.RawQuery, header: req.Header, + rawQuery: rawQuery, header: req.Header, body: http.MaxBytesReader(w, req.Body, maxBody), }) if err != nil { @@ -261,7 +280,7 @@ func (p *proxy) httpProxyLimit(agentPath string, maxBody int64) http.HandlerFunc } defer func() { _ = resp.Body.Close() }() copyResponseHeaders(w.Header(), resp.Header) - if strings.HasSuffix(agentPath, "/download") { + if strings.HasSuffix(agentPath, "/download") || (agentPath == "/files/archive" && req.Method == http.MethodGet) { forceAttachment(w.Header()) } w.WriteHeader(resp.StatusCode) diff --git a/api/specs.md b/api/specs.md index 469452d18..687759d67 100644 --- a/api/specs.md +++ b/api/specs.md @@ -656,6 +656,7 @@ Foreign keys are enforced only on Postgres (modernc-sqlite runs with FK OFF); th - **Selected-cluster capture files:** GET `/servers/{name}:capture-file` and file cleanup during DELETE `:capture` use the private gateway for remote targets. Both server and capture UIDs are checked at the gateway and sidecar. Remote deletion accepts only a 204 bound cleanup acknowledgement or a 410 confirming both identity and PCAP absence before UID-guarded record deletion; ambiguous errors retain the record for retry. Cleanup may be skipped only for Completed captures with current-generation SidecarStopped=True, reason never_started, and no recorded gameplane.local/capture-pod-uid annotation, after validating server ownership. Pending/Running captures still require stop first; Failed phase or missing completion time is not absence proof. Existing local transport and cleanup behavior remain supported. Capture permissions and synchronous audit requirements do not change. Missing/old gateways fail closed, with no local fallback. - **Server capabilities:** GET `/servers/{name}/capabilities` requires the normal target-scoped server read permission and returns the exact target plus `capture.enabled`, `capture.files` and `capture.state`. Local enablement uses central configuration; remote enablement comes from the authenticated selected gateway. States are `ready`, `unsupported` or `unavailable`. Capabilities describe transport support and never grant capture permission. - **Central administration:** cluster node-join/kubeconfig, users, provider credentials, module catalog and installation management remain central administrative operations. `TestHomeClientMounts_ServeHomeClusterOnly` and `TestHomeClientMounts_MatchMain` retain their local-client boundary checks. Remote routing, identity and isolation tests cover the registry-backed mounts separately. +- **File selection archives:** GET `/servers/{name}/files/archive` returns an attachment; POST creates a ZIP on the server. Both proxy repeated `path` parameters, preserve agent errors and honor the selected cluster and namespace. File manager requests can carry `X-Gameplane-Server-UID`; direct browser downloads can supply `expectedUID` in the query. The API rejects a mismatch with 409 before contacting the agent. - **Gateway-enabled agent routes:** the production mount enables selected-cluster RCON, game-file logs/downloads, files, players, status and agent-based mods through the optional gateway. RCON module actions use that gateway; stdin actions, Pod logs and PTY attach use the selected Kubernetes client. GET `/servers/{name}/mods/updates` uses `MountModUpdatesWithRegistry` so its template and agent reads target the same selected cluster. Consumers mounted without the remote resolver retain the legacy 501 guard; missing or invalid gateway configuration fails closed rather than using a local agent. Tests: `TestModUpdatesRoutesAgentAndTemplateToRemoteCluster`, `TestModUpdatesRemoteCannotUseLocalOnlyAgent` and `ws/gateway_client_test.go` cover remote routing and fail-closed behavior. ### GameServer config password redaction diff --git a/assets/design-export/MANIFEST.md b/assets/design-export/MANIFEST.md index b7e0ccd34..b786d2c18 100644 --- a/assets/design-export/MANIFEST.md +++ b/assets/design-export/MANIFEST.md @@ -2874,3 +2874,7 @@ Nodes now follows the Backups toolbar: a plain search input, capped at 320px on All seven roots have JSON from Pencil `Get` and 2× PNG from `Export`. Toolbar, popover, desktop, fixed-cluster and mobile layouts were inspected using `TakeScreenshot`; the full screens retain their previous dimensions and content. JSON exports retain component references without elided children or editing placeholders. No React source, tests or lint suites were changed or run for this design update. + +## Server file workspace + +The Files screen (`Burtr`) shows selection checkboxes, folder expansion, open editor tabs and bulk actions. The Create ZIP dialog (`o1uEP`) asks for the archive filename. Both frames have JSON and PNG exports. diff --git a/assets/design-export/json/Burtr.json b/assets/design-export/json/Burtr.json index e41fd5ac6..2b9a9b8d5 100644 --- a/assets/design-export/json/Burtr.json +++ b/assets/design-export/json/Burtr.json @@ -75,7 +75,7 @@ "name": "Tabs Bar", "width": "fill_container", "padding": [ - 0, + 18, 24 ], "descendants": { @@ -102,6 +102,12 @@ "fill": "$background/background", "layout": "vertical", "gap": 12, + "padding": [ + 4, + 0, + 0, + 0 + ], "children": [ { "type": "frame", @@ -205,6 +211,181 @@ } ] }, + { + "type": "frame", + "id": "AHymw", + "name": "Selected file actions", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "NskME", + "fill": "$foreground/foreground", + "content": "2 selected", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "DDLse", + "name": "Download ZIP", + "height": 32, + "fill": "$accent/accent", + "cornerRadius": 6, + "padding": [ + 5, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "NEfnl", + "fill": "$accent/foreground", + "content": "Download ZIP", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "B9GUU", + "name": "Create ZIP", + "height": 32, + "fill": "$accent/accent", + "cornerRadius": 6, + "padding": [ + 5, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "cTVra", + "fill": "$accent/foreground", + "content": "Create ZIP", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "LtJ0P", + "name": "Delete selected", + "height": 32, + "fill": "$danger/danger", + "cornerRadius": 6, + "padding": [ + 5, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "mMYEH", + "fill": "$danger/foreground", + "content": "Delete selected", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "VNDEL", + "name": "Open files", + "width": "fill_container", + "fill": "$background/background", + "stroke": "$border/border", + "strokeWidth": { + "bottom": 1 + }, + "gap": 4, + "padding": [ + 4, + 8 + ], + "children": [ + { + "type": "frame", + "id": "G92Jq", + "name": "/eula.txt", + "fill": "$background/background", + "gap": 6, + "padding": [ + 7, + 9 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "kl8eO", + "fill": "$foreground/foreground", + "content": "/eula.txt", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "icon", + "id": "y0Qia", + "width": 12, + "height": 12, + "icon": "x", + "library": "lucide", + "weight": 400, + "fill": "$muted" + } + ] + }, + { + "type": "frame", + "id": "ZU2YT", + "name": "/server.properties", + "fill": "$default/default", + "gap": 6, + "padding": [ + 7, + 9 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "CCT3t", + "fill": "$foreground/foreground", + "content": "/server.properties", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "icon", + "id": "JCZVm", + "width": 12, + "height": 12, + "icon": "x", + "library": "lucide", + "weight": 400, + "fill": "$muted" + } + ] + } + ] + }, { "type": "frame", "id": "xgRAR", @@ -271,17 +452,17 @@ "gap": 2, "children": [ { - "id": "F6YVV0", + "id": "TZIn6", "type": "ref", "ref": "tDY4O", - "name": "Row — .cache", + "name": "Row — libraries", "width": "fill_container", "layout": "horizontal", "justifyContent": "space_between", "alignItems": "center", "children": [ { - "id": "iphte", + "id": "UBnid", "type": "ref", "ref": "TDRJE", "name": "Name", @@ -293,159 +474,31 @@ "children": [ { "type": "frame", - "id": "yPQB5", + "id": "t7zL0k", "name": "Left", "gap": 8, "alignItems": "center", "children": [ { "type": "icon", - "id": "XIDqU", - "name": "Icon", - "width": 12, - "height": 12, - "icon": "folder", + "id": "u1Ur2H", + "width": 14, + "height": 14, + "icon": "square", "library": "lucide", "weight": 400, "fill": "$muted" }, - { - "type": "text", - "id": "fxEFY", - "name": "Name", - "fill": "$foreground/foreground", - "content": ".cache", - "fontFamily": "$typography/font-sans", - "fontSize": 14, - "fontWeight": "normal" - } - ] - } - ] - }, - { - "id": "nthaG", - "type": "ref", - "ref": "TDRJE", - "name": "Size", - "padding": [ - 4, - 12 - ], - "descendants": { - "HNAOu": { - "content": "", - "fontFamily": "$typography/font-sans", - "fontSize": 12, - "fontWeight": "normal", - "fill": "$muted" - } - } - } - ] - }, - { - "id": "S19E0R", - "type": "ref", - "ref": "tDY4O", - "name": "Row — .fabric", - "width": "fill_container", - "layout": "horizontal", - "justifyContent": "space_between", - "alignItems": "center", - "children": [ - { - "id": "wRq0E", - "type": "ref", - "ref": "TDRJE", - "name": "Name", - "width": "fill_container", - "padding": [ - 4, - 12 - ], - "children": [ - { - "type": "frame", - "id": "h9H01t", - "name": "Left", - "gap": 8, - "alignItems": "center", - "children": [ { "type": "icon", - "id": "XSuiZ", - "name": "Icon", + "id": "jsx0l", "width": 12, "height": 12, - "icon": "folder", + "icon": "chevron-right", "library": "lucide", "weight": 400, "fill": "$muted" }, - { - "type": "text", - "id": "AWehP", - "name": "Name", - "fill": "$foreground/foreground", - "content": ".fabric", - "fontFamily": "$typography/font-sans", - "fontSize": 14, - "fontWeight": "normal" - } - ] - } - ] - }, - { - "id": "VMvbL", - "type": "ref", - "ref": "TDRJE", - "name": "Size", - "padding": [ - 4, - 12 - ], - "descendants": { - "HNAOu": { - "content": "", - "fontFamily": "$typography/font-sans", - "fontSize": 12, - "fontWeight": "normal", - "fill": "$muted" - } - } - } - ] - }, - { - "id": "TZIn6", - "type": "ref", - "ref": "tDY4O", - "name": "Row — libraries", - "width": "fill_container", - "layout": "horizontal", - "justifyContent": "space_between", - "alignItems": "center", - "children": [ - { - "id": "UBnid", - "type": "ref", - "ref": "TDRJE", - "name": "Name", - "width": "fill_container", - "padding": [ - 4, - 12 - ], - "children": [ - { - "type": "frame", - "id": "t7zL0k", - "name": "Left", - "gap": 8, - "alignItems": "center", - "children": [ { "type": "icon", "id": "yR2lf", @@ -521,6 +574,26 @@ "gap": 8, "alignItems": "center", "children": [ + { + "type": "icon", + "id": "BokiF", + "width": 14, + "height": 14, + "icon": "square", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, + { + "type": "icon", + "id": "PujKi", + "width": 12, + "height": 12, + "icon": "chevron-down", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, { "type": "icon", "id": "YK4Zq", @@ -571,172 +644,66 @@ "height": 14, "fill": "$danger/danger" } - } - } - ] - } - ] - }, - { - "id": "lKTo9", - "type": "ref", - "ref": "tDY4O", - "name": "Row — mods", - "width": "fill_container", - "layout": "horizontal", - "justifyContent": "space_between", - "alignItems": "center", - "children": [ - { - "id": "D8jLD", - "type": "ref", - "ref": "TDRJE", - "name": "Name", - "width": "fill_container", - "padding": [ - 4, - 12 - ], - "children": [ - { - "type": "frame", - "id": "O5ymB3", - "name": "Left", - "gap": 8, - "alignItems": "center", - "children": [ - { - "type": "icon", - "id": "MFib6", - "name": "Icon", - "width": 12, - "height": 12, - "icon": "folder", - "library": "lucide", - "weight": 400, - "fill": "$muted" - }, - { - "type": "text", - "id": "Yzlzp", - "name": "Name", - "fill": "$foreground/foreground", - "content": "mods", - "fontFamily": "$typography/font-sans", - "fontSize": 14, - "fontWeight": "normal" - } - ] - } - ] - }, - { - "id": "dqQ82", - "type": "ref", - "ref": "TDRJE", - "name": "Size", - "padding": [ - 4, - 12 - ], - "descendants": { - "HNAOu": { - "content": "", - "fontFamily": "$typography/font-sans", - "fontSize": 12, - "fontWeight": "normal", - "fill": "$muted" - } - } - } - ] - }, - { - "id": "KE0XP", - "type": "ref", - "ref": "tDY4O", - "name": "Row — versions", - "width": "fill_container", - "layout": "horizontal", - "justifyContent": "space_between", - "alignItems": "center", - "children": [ - { - "id": "wH6w7", - "type": "ref", - "ref": "TDRJE", - "name": "Name", - "width": "fill_container", - "padding": [ - 4, - 12 - ], - "children": [ - { - "type": "frame", - "id": "R7CKZ", - "name": "Left", - "gap": 8, - "alignItems": "center", - "children": [ - { - "type": "icon", - "id": "DSvvO", - "name": "Icon", - "width": 12, - "height": 12, - "icon": "folder", - "library": "lucide", - "weight": 400, - "fill": "$muted" - }, - { - "type": "text", - "id": "vNyu5", - "name": "Name", - "fill": "$foreground/foreground", - "content": "versions", - "fontFamily": "$typography/font-sans", - "fontSize": 14, - "fontWeight": "normal" - } - ] + } } ] + } + ] + }, + { + "type": "frame", + "id": "zb75d", + "name": "Latest log in expanded logs folder", + "gap": 8, + "padding": [ + 4, + 28 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "k3qAiB", + "width": 14, + "height": 14, + "icon": "square", + "library": "lucide", + "weight": 400, + "fill": "$muted" }, { - "id": "V7znPz", - "type": "ref", - "ref": "TDRJE", - "name": "Size", - "padding": [ - 4, - 12 - ], - "descendants": { - "HNAOu": { - "content": "", - "fontFamily": "$typography/font-sans", - "fontSize": 12, - "fontWeight": "normal", - "fill": "$muted" - } - } + "type": "icon", + "id": "s3WRK", + "width": 12, + "height": 12, + "icon": "file", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, + { + "type": "text", + "id": "hgxeI", + "fill": "$foreground/foreground", + "content": "latest.log", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" } ] }, { - "id": "siXy4", + "id": "lKTo9", "type": "ref", "ref": "tDY4O", - "name": "Row — world", + "name": "Row — mods", "width": "fill_container", "layout": "horizontal", "justifyContent": "space_between", "alignItems": "center", "children": [ { - "id": "ZZpYt", + "id": "D8jLD", "type": "ref", "ref": "TDRJE", "name": "Name", @@ -748,14 +715,34 @@ "children": [ { "type": "frame", - "id": "ZYZoi", + "id": "O5ymB3", "name": "Left", "gap": 8, "alignItems": "center", "children": [ { "type": "icon", - "id": "KeFsh", + "id": "t3RXx", + "width": 14, + "height": 14, + "icon": "square", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, + { + "type": "icon", + "id": "c84kb", + "width": 12, + "height": 12, + "icon": "chevron-right", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, + { + "type": "icon", + "id": "MFib6", "name": "Icon", "width": 12, "height": 12, @@ -766,10 +753,10 @@ }, { "type": "text", - "id": "y17yd", + "id": "Yzlzp", "name": "Name", "fill": "$foreground/foreground", - "content": "world", + "content": "mods", "fontFamily": "$typography/font-sans", "fontSize": 14, "fontWeight": "normal" @@ -779,7 +766,7 @@ ] }, { - "id": "Z2rGR", + "id": "dqQ82", "type": "ref", "ref": "TDRJE", "name": "Size", @@ -800,17 +787,17 @@ ] }, { - "id": "EfFca", + "id": "KE0XP", "type": "ref", "ref": "tDY4O", - "name": "Row — .fabric-manifest.json", + "name": "Row — versions", "width": "fill_container", "layout": "horizontal", "justifyContent": "space_between", "alignItems": "center", "children": [ { - "id": "j1tYD", + "id": "wH6w7", "type": "ref", "ref": "TDRJE", "name": "Name", @@ -822,28 +809,48 @@ "children": [ { "type": "frame", - "id": "nT69V", + "id": "R7CKZ", "name": "Left", "gap": 8, "alignItems": "center", "children": [ { "type": "icon", - "id": "YZ1LM", + "id": "eoLOO", + "width": 14, + "height": 14, + "icon": "square", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, + { + "type": "icon", + "id": "Np6Jq", + "width": 12, + "height": 12, + "icon": "chevron-right", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, + { + "type": "icon", + "id": "DSvvO", "name": "Icon", "width": 12, "height": 12, - "icon": "file", + "icon": "folder", "library": "lucide", "weight": 400, "fill": "$muted" }, { "type": "text", - "id": "TjKRN", + "id": "vNyu5", "name": "Name", "fill": "$foreground/foreground", - "content": ".fabric-manifest.json", + "content": "versions", "fontFamily": "$typography/font-sans", "fontSize": 14, "fontWeight": "normal" @@ -853,7 +860,7 @@ ] }, { - "id": "g4WOk", + "id": "V7znPz", "type": "ref", "ref": "TDRJE", "name": "Size", @@ -863,7 +870,7 @@ ], "descendants": { "HNAOu": { - "content": "323 B", + "content": "", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal", @@ -874,17 +881,17 @@ ] }, { - "id": "vlGX2", + "id": "siXy4", "type": "ref", "ref": "tDY4O", - "name": "Row — .rcon-cli.yaml", + "name": "Row — world", "width": "fill_container", "layout": "horizontal", "justifyContent": "space_between", "alignItems": "center", "children": [ { - "id": "NWx9C", + "id": "ZZpYt", "type": "ref", "ref": "TDRJE", "name": "Name", @@ -896,28 +903,48 @@ "children": [ { "type": "frame", - "id": "UbcYh", + "id": "ZYZoi", "name": "Left", "gap": 8, "alignItems": "center", "children": [ { "type": "icon", - "id": "SN9hj", + "id": "cASz7", + "width": 14, + "height": 14, + "icon": "square", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, + { + "type": "icon", + "id": "NYbLW", + "width": 12, + "height": 12, + "icon": "chevron-right", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, + { + "type": "icon", + "id": "KeFsh", "name": "Icon", "width": 12, "height": 12, - "icon": "file", + "icon": "folder", "library": "lucide", "weight": 400, "fill": "$muted" }, { "type": "text", - "id": "HLzf2", + "id": "y17yd", "name": "Name", "fill": "$foreground/foreground", - "content": ".rcon-cli.yaml", + "content": "world", "fontFamily": "$typography/font-sans", "fontSize": 14, "fontWeight": "normal" @@ -927,7 +954,7 @@ ] }, { - "id": "koylq", + "id": "Z2rGR", "type": "ref", "ref": "TDRJE", "name": "Size", @@ -937,7 +964,7 @@ ], "descendants": { "HNAOu": { - "content": "45 B", + "content": "", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal", @@ -975,6 +1002,16 @@ "gap": 8, "alignItems": "center", "children": [ + { + "type": "icon", + "id": "yfba0", + "width": 14, + "height": 14, + "icon": "square", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, { "type": "icon", "id": "uD7mU", @@ -1049,6 +1086,16 @@ "gap": 8, "alignItems": "center", "children": [ + { + "type": "icon", + "id": "DX9BY", + "width": 14, + "height": 14, + "icon": "square-check", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, { "type": "icon", "id": "E4AmEv", @@ -1123,6 +1170,16 @@ "gap": 8, "alignItems": "center", "children": [ + { + "type": "icon", + "id": "HAzBt", + "width": 14, + "height": 14, + "icon": "square", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, { "type": "icon", "id": "ZVGcT", @@ -1197,6 +1254,16 @@ "gap": 8, "alignItems": "center", "children": [ + { + "type": "icon", + "id": "xEkYU", + "width": 14, + "height": 14, + "icon": "square", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, { "type": "icon", "id": "UOTQV", @@ -1252,6 +1319,7 @@ "layout": "horizontal", "justifyContent": "space_between", "alignItems": "center", + "fill": "$accent/soft", "children": [ { "id": "DZeYC", @@ -1271,6 +1339,16 @@ "gap": 8, "alignItems": "center", "children": [ + { + "type": "icon", + "id": "mLyol", + "width": 14, + "height": 14, + "icon": "square-check", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, { "type": "icon", "id": "hqll3", @@ -1345,6 +1423,16 @@ "gap": 8, "alignItems": "center", "children": [ + { + "type": "icon", + "id": "y8bvm8", + "width": 14, + "height": 14, + "icon": "square", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, { "type": "icon", "id": "pmA3B", @@ -1419,6 +1507,16 @@ "gap": 8, "alignItems": "center", "children": [ + { + "type": "icon", + "id": "xFiOo", + "width": 14, + "height": 14, + "icon": "square", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, { "type": "icon", "id": "S1zsf", @@ -1476,18 +1574,160 @@ "width": "fill_container", "height": "fill_container", "layout": "vertical", - "justifyContent": "center", - "alignItems": "center", "children": [ { - "type": "text", - "id": "FinLx", - "name": "Empty State", - "fill": "$muted", - "content": "Select a file to edit.", - "fontFamily": "$typography/font-sans", - "fontSize": 14, - "fontWeight": "normal" + "type": "frame", + "id": "W7g141", + "name": "Editor actions", + "width": "fill_container", + "gap": 8, + "padding": [ + 8, + 12 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "jl6Cv", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "CIbNf", + "width": 14, + "height": 14, + "icon": "file", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, + { + "type": "text", + "id": "bPGpW", + "fill": "$foreground/foreground", + "content": "server.properties", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "qbxbE", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "fiKLY", + "name": "Download", + "fill": "$default/default", + "cornerRadius": 6, + "padding": [ + 5, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "l6nNp", + "fill": "$foreground/foreground", + "content": "Download", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "XbBHf", + "name": "Delete", + "fill": "$default/default", + "cornerRadius": 6, + "padding": [ + 5, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "RAZ1s", + "fill": "$foreground/foreground", + "content": "Delete", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "xydTH", + "name": "Save", + "fill": "$default/default", + "cornerRadius": 6, + "padding": [ + 5, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "v1Uj7e", + "fill": "$foreground/foreground", + "content": "Save", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "peDru", + "name": "Monaco editor", + "width": "fill_container", + "height": "fill_container", + "fill": "#1E1E1E", + "gap": 22, + "padding": [ + 0, + 28 + ], + "children": [ + { + "type": "text", + "id": "BVuqs", + "fill": "$muted", + "content": "1\n2\n3\n4\n5\n6", + "lineHeight": 1.55, + "fontFamily": "$typography/font-mono", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "ETZHc", + "fill": "$foreground/foreground", + "content": "#Minecraft server properties\nmotd=Survival server\ndifficulty=normal\ngamemode=survival\nmax-players=20\nwhite-list=true", + "lineHeight": 1.55, + "fontFamily": "$typography/font-mono", + "fontSize": 13, + "fontWeight": "normal" + } + ] } ] } diff --git a/assets/design-export/json/o1uEP.json b/assets/design-export/json/o1uEP.json new file mode 100644 index 000000000..f3d413a63 --- /dev/null +++ b/assets/design-export/json/o1uEP.json @@ -0,0 +1,85 @@ +{ + "id": "o1uEP", + "type": "ref", + "reusable": true, + "ref": "x3beP", + "name": "Gameplane/Dialog/Create ZIP", + "width": 448, + "x": 4240, + "y": 6274, + "descendants": { + "E7Whx": { + "content": "Create ZIP" + }, + "ABbjS": { + "enabled": false, + "width": 1, + "height": 0 + }, + "qzcst": { + "enabled": false, + "width": 1 + }, + "Nht52": { + "type": "frame", + "id": "USuhP", + "name": "Archive name", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 0, + 0, + 0 + ], + "children": [ + { + "type": "text", + "id": "KcxUD", + "fill": "$muted", + "content": "Archive filename (.zip)", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "id": "M5ATf5", + "type": "ref", + "ref": "D0cDM", + "name": "Archive filename", + "width": "fill_container", + "stroke": "$accent/accent", + "strokeWidth": 2, + "effect": [], + "descendants": { + "PJERm": { + "content": "" + } + } + } + ] + }, + "SBdeH": { + "height": 36, + "padding": [ + 0, + 16 + ] + }, + "REcmP": { + "fontSize": 14 + }, + "V7HYp": { + "height": 36, + "padding": [ + 0, + 16 + ] + }, + "TZ7Ef": { + "content": "Create", + "fontSize": 14 + } + } +} diff --git a/assets/design-export/screenshots/Burtr.png b/assets/design-export/screenshots/Burtr.png index 201280953..f499cdc37 100644 Binary files a/assets/design-export/screenshots/Burtr.png and b/assets/design-export/screenshots/Burtr.png differ diff --git a/assets/design-export/screenshots/o1uEP.png b/assets/design-export/screenshots/o1uEP.png new file mode 100644 index 000000000..ced363be0 Binary files /dev/null and b/assets/design-export/screenshots/o1uEP.png differ diff --git a/changelog/unreleased/935-file-workspace.md b/changelog/unreleased/935-file-workspace.md new file mode 100644 index 000000000..c1c588fa1 --- /dev/null +++ b/changelog/unreleased/935-file-workspace.md @@ -0,0 +1,6 @@ +# Server file manager + +Expand folders, link directly to a file and switch between open editor tabs. +Edits autosave in order and failed saves remain available for retry during +the session. Select files and folders to download a ZIP, save an archive on +the server or delete them together. [Issue #935](https://github.com/GameplanePanel/Gameplane/issues/935) diff --git a/design.pen b/design.pen index 876a2bbb1..6b8242c65 100644 --- a/design.pen +++ b/design.pen @@ -1,5 +1,5 @@ { - "version": "2.21", + "version": "2.22", "children": [ { "type": "frame", @@ -4323,7 +4323,7 @@ "name": "Tabs Bar", "width": "fill_container", "padding": [ - 0, + 18, 24 ], "descendants": { @@ -4350,6 +4350,12 @@ "fill": "$background/background", "layout": "vertical", "gap": 12, + "padding": [ + 4, + 0, + 0, + 0 + ], "children": [ { "type": "frame", @@ -4453,6 +4459,181 @@ } ] }, + { + "type": "frame", + "id": "AHymw", + "name": "Selected file actions", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "NskME", + "fill": "$foreground/foreground", + "content": "2 selected", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "DDLse", + "name": "Download ZIP", + "height": 32, + "fill": "$accent/accent", + "cornerRadius": 6, + "padding": [ + 5, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "NEfnl", + "fill": "$accent/foreground", + "content": "Download ZIP", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "B9GUU", + "name": "Create ZIP", + "height": 32, + "fill": "$accent/accent", + "cornerRadius": 6, + "padding": [ + 5, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "cTVra", + "fill": "$accent/foreground", + "content": "Create ZIP", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "LtJ0P", + "name": "Delete selected", + "height": 32, + "fill": "$danger/danger", + "cornerRadius": 6, + "padding": [ + 5, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "mMYEH", + "fill": "$danger/foreground", + "content": "Delete selected", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "VNDEL", + "name": "Open files", + "width": "fill_container", + "fill": "$background/background", + "stroke": "$border/border", + "strokeWidth": { + "bottom": 1 + }, + "gap": 4, + "padding": [ + 4, + 8 + ], + "children": [ + { + "type": "frame", + "id": "G92Jq", + "name": "/eula.txt", + "fill": "$background/background", + "gap": 6, + "padding": [ + 7, + 9 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "kl8eO", + "fill": "$foreground/foreground", + "content": "/eula.txt", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "icon", + "id": "y0Qia", + "width": 12, + "height": 12, + "icon": "x", + "library": "lucide", + "weight": 400, + "fill": "$muted" + } + ] + }, + { + "type": "frame", + "id": "ZU2YT", + "name": "/server.properties", + "fill": "$default/default", + "gap": 6, + "padding": [ + 7, + 9 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "CCT3t", + "fill": "$foreground/foreground", + "content": "/server.properties", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "icon", + "id": "JCZVm", + "width": 12, + "height": 12, + "icon": "x", + "library": "lucide", + "weight": 400, + "fill": "$muted" + } + ] + } + ] + }, { "type": "frame", "id": "xgRAR", @@ -4519,17 +4700,17 @@ "gap": 2, "children": [ { - "id": "F6YVV0", + "id": "TZIn6", "type": "ref", "ref": "tDY4O", - "name": "Row — .cache", + "name": "Row — libraries", "width": "fill_container", "layout": "horizontal", "justifyContent": "space_between", "alignItems": "center", "children": [ { - "id": "iphte", + "id": "UBnid", "type": "ref", "ref": "TDRJE", "name": "Name", @@ -4541,159 +4722,31 @@ "children": [ { "type": "frame", - "id": "yPQB5", + "id": "t7zL0k", "name": "Left", "gap": 8, "alignItems": "center", "children": [ { "type": "icon", - "id": "XIDqU", - "name": "Icon", - "width": 12, - "height": 12, - "icon": "folder", + "id": "u1Ur2H", + "width": 14, + "height": 14, + "icon": "square", "library": "lucide", "weight": 400, "fill": "$muted" }, - { - "type": "text", - "id": "fxEFY", - "name": "Name", - "fill": "$foreground/foreground", - "content": ".cache", - "fontFamily": "$typography/font-sans", - "fontSize": 14, - "fontWeight": "normal" - } - ] - } - ] - }, - { - "id": "nthaG", - "type": "ref", - "ref": "TDRJE", - "name": "Size", - "padding": [ - 4, - 12 - ], - "descendants": { - "HNAOu": { - "content": "", - "fontFamily": "$typography/font-sans", - "fontSize": 12, - "fontWeight": "normal", - "fill": "$muted" - } - } - } - ] - }, - { - "id": "S19E0R", - "type": "ref", - "ref": "tDY4O", - "name": "Row — .fabric", - "width": "fill_container", - "layout": "horizontal", - "justifyContent": "space_between", - "alignItems": "center", - "children": [ - { - "id": "wRq0E", - "type": "ref", - "ref": "TDRJE", - "name": "Name", - "width": "fill_container", - "padding": [ - 4, - 12 - ], - "children": [ - { - "type": "frame", - "id": "h9H01t", - "name": "Left", - "gap": 8, - "alignItems": "center", - "children": [ { "type": "icon", - "id": "XSuiZ", - "name": "Icon", + "id": "jsx0l", "width": 12, "height": 12, - "icon": "folder", + "icon": "chevron-right", "library": "lucide", "weight": 400, "fill": "$muted" }, - { - "type": "text", - "id": "AWehP", - "name": "Name", - "fill": "$foreground/foreground", - "content": ".fabric", - "fontFamily": "$typography/font-sans", - "fontSize": 14, - "fontWeight": "normal" - } - ] - } - ] - }, - { - "id": "VMvbL", - "type": "ref", - "ref": "TDRJE", - "name": "Size", - "padding": [ - 4, - 12 - ], - "descendants": { - "HNAOu": { - "content": "", - "fontFamily": "$typography/font-sans", - "fontSize": 12, - "fontWeight": "normal", - "fill": "$muted" - } - } - } - ] - }, - { - "id": "TZIn6", - "type": "ref", - "ref": "tDY4O", - "name": "Row — libraries", - "width": "fill_container", - "layout": "horizontal", - "justifyContent": "space_between", - "alignItems": "center", - "children": [ - { - "id": "UBnid", - "type": "ref", - "ref": "TDRJE", - "name": "Name", - "width": "fill_container", - "padding": [ - 4, - 12 - ], - "children": [ - { - "type": "frame", - "id": "t7zL0k", - "name": "Left", - "gap": 8, - "alignItems": "center", - "children": [ { "type": "icon", "id": "yR2lf", @@ -4769,6 +4822,26 @@ "gap": 8, "alignItems": "center", "children": [ + { + "type": "icon", + "id": "BokiF", + "width": 14, + "height": 14, + "icon": "square", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, + { + "type": "icon", + "id": "PujKi", + "width": 12, + "height": 12, + "icon": "chevron-down", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, { "type": "icon", "id": "YK4Zq", @@ -4825,6 +4898,48 @@ } ] }, + { + "type": "frame", + "id": "zb75d", + "name": "Latest log in expanded logs folder", + "gap": 8, + "padding": [ + 4, + 28 + ], + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "k3qAiB", + "width": 14, + "height": 14, + "icon": "square", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, + { + "type": "icon", + "id": "s3WRK", + "width": 12, + "height": 12, + "icon": "file", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, + { + "type": "text", + "id": "hgxeI", + "fill": "$foreground/foreground", + "content": "latest.log", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, { "id": "lKTo9", "type": "ref", @@ -4853,6 +4968,26 @@ "gap": 8, "alignItems": "center", "children": [ + { + "type": "icon", + "id": "t3RXx", + "width": 14, + "height": 14, + "icon": "square", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, + { + "type": "icon", + "id": "c84kb", + "width": 12, + "height": 12, + "icon": "chevron-right", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, { "type": "icon", "id": "MFib6", @@ -4927,6 +5062,26 @@ "gap": 8, "alignItems": "center", "children": [ + { + "type": "icon", + "id": "eoLOO", + "width": 14, + "height": 14, + "icon": "square", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, + { + "type": "icon", + "id": "Np6Jq", + "width": 12, + "height": 12, + "icon": "chevron-right", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, { "type": "icon", "id": "DSvvO", @@ -5003,169 +5158,41 @@ "children": [ { "type": "icon", - "id": "KeFsh", - "name": "Icon", - "width": 12, - "height": 12, - "icon": "folder", + "id": "cASz7", + "width": 14, + "height": 14, + "icon": "square", "library": "lucide", "weight": 400, "fill": "$muted" }, - { - "type": "text", - "id": "y17yd", - "name": "Name", - "fill": "$foreground/foreground", - "content": "world", - "fontFamily": "$typography/font-sans", - "fontSize": 14, - "fontWeight": "normal" - } - ] - } - ] - }, - { - "id": "Z2rGR", - "type": "ref", - "ref": "TDRJE", - "name": "Size", - "padding": [ - 4, - 12 - ], - "descendants": { - "HNAOu": { - "content": "", - "fontFamily": "$typography/font-sans", - "fontSize": 12, - "fontWeight": "normal", - "fill": "$muted" - } - } - } - ] - }, - { - "id": "EfFca", - "type": "ref", - "ref": "tDY4O", - "name": "Row — .fabric-manifest.json", - "width": "fill_container", - "layout": "horizontal", - "justifyContent": "space_between", - "alignItems": "center", - "children": [ - { - "id": "j1tYD", - "type": "ref", - "ref": "TDRJE", - "name": "Name", - "width": "fill_container", - "padding": [ - 4, - 12 - ], - "children": [ - { - "type": "frame", - "id": "nT69V", - "name": "Left", - "gap": 8, - "alignItems": "center", - "children": [ { "type": "icon", - "id": "YZ1LM", - "name": "Icon", + "id": "NYbLW", "width": 12, "height": 12, - "icon": "file", + "icon": "chevron-right", "library": "lucide", "weight": 400, "fill": "$muted" }, - { - "type": "text", - "id": "TjKRN", - "name": "Name", - "fill": "$foreground/foreground", - "content": ".fabric-manifest.json", - "fontFamily": "$typography/font-sans", - "fontSize": 14, - "fontWeight": "normal" - } - ] - } - ] - }, - { - "id": "g4WOk", - "type": "ref", - "ref": "TDRJE", - "name": "Size", - "padding": [ - 4, - 12 - ], - "descendants": { - "HNAOu": { - "content": "323 B", - "fontFamily": "$typography/font-sans", - "fontSize": 12, - "fontWeight": "normal", - "fill": "$muted" - } - } - } - ] - }, - { - "id": "vlGX2", - "type": "ref", - "ref": "tDY4O", - "name": "Row — .rcon-cli.yaml", - "width": "fill_container", - "layout": "horizontal", - "justifyContent": "space_between", - "alignItems": "center", - "children": [ - { - "id": "NWx9C", - "type": "ref", - "ref": "TDRJE", - "name": "Name", - "width": "fill_container", - "padding": [ - 4, - 12 - ], - "children": [ - { - "type": "frame", - "id": "UbcYh", - "name": "Left", - "gap": 8, - "alignItems": "center", - "children": [ { "type": "icon", - "id": "SN9hj", + "id": "KeFsh", "name": "Icon", "width": 12, "height": 12, - "icon": "file", + "icon": "folder", "library": "lucide", "weight": 400, "fill": "$muted" }, { "type": "text", - "id": "HLzf2", + "id": "y17yd", "name": "Name", "fill": "$foreground/foreground", - "content": ".rcon-cli.yaml", + "content": "world", "fontFamily": "$typography/font-sans", "fontSize": 14, "fontWeight": "normal" @@ -5175,7 +5202,7 @@ ] }, { - "id": "koylq", + "id": "Z2rGR", "type": "ref", "ref": "TDRJE", "name": "Size", @@ -5185,7 +5212,7 @@ ], "descendants": { "HNAOu": { - "content": "45 B", + "content": "", "fontFamily": "$typography/font-sans", "fontSize": 12, "fontWeight": "normal", @@ -5223,6 +5250,16 @@ "gap": 8, "alignItems": "center", "children": [ + { + "type": "icon", + "id": "yfba0", + "width": 14, + "height": 14, + "icon": "square", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, { "type": "icon", "id": "uD7mU", @@ -5297,6 +5334,16 @@ "gap": 8, "alignItems": "center", "children": [ + { + "type": "icon", + "id": "DX9BY", + "width": 14, + "height": 14, + "icon": "square-check", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, { "type": "icon", "id": "E4AmEv", @@ -5371,6 +5418,16 @@ "gap": 8, "alignItems": "center", "children": [ + { + "type": "icon", + "id": "HAzBt", + "width": 14, + "height": 14, + "icon": "square", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, { "type": "icon", "id": "ZVGcT", @@ -5445,6 +5502,16 @@ "gap": 8, "alignItems": "center", "children": [ + { + "type": "icon", + "id": "xEkYU", + "width": 14, + "height": 14, + "icon": "square", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, { "type": "icon", "id": "UOTQV", @@ -5500,6 +5567,7 @@ "layout": "horizontal", "justifyContent": "space_between", "alignItems": "center", + "fill": "$accent/soft", "children": [ { "id": "DZeYC", @@ -5519,6 +5587,16 @@ "gap": 8, "alignItems": "center", "children": [ + { + "type": "icon", + "id": "mLyol", + "width": 14, + "height": 14, + "icon": "square-check", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, { "type": "icon", "id": "hqll3", @@ -5593,6 +5671,16 @@ "gap": 8, "alignItems": "center", "children": [ + { + "type": "icon", + "id": "y8bvm8", + "width": 14, + "height": 14, + "icon": "square", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, { "type": "icon", "id": "pmA3B", @@ -5667,6 +5755,16 @@ "gap": 8, "alignItems": "center", "children": [ + { + "type": "icon", + "id": "xFiOo", + "width": 14, + "height": 14, + "icon": "square", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, { "type": "icon", "id": "S1zsf", @@ -5724,18 +5822,160 @@ "width": "fill_container", "height": "fill_container", "layout": "vertical", - "justifyContent": "center", - "alignItems": "center", "children": [ { - "type": "text", - "id": "FinLx", - "name": "Empty State", - "fill": "$muted", - "content": "Select a file to edit.", - "fontFamily": "$typography/font-sans", - "fontSize": 14, - "fontWeight": "normal" + "type": "frame", + "id": "W7g141", + "name": "Editor actions", + "width": "fill_container", + "gap": 8, + "padding": [ + 8, + 12 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "jl6Cv", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "CIbNf", + "width": 14, + "height": 14, + "icon": "file", + "library": "lucide", + "weight": 400, + "fill": "$muted" + }, + { + "type": "text", + "id": "bPGpW", + "fill": "$foreground/foreground", + "content": "server.properties", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "qbxbE", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "fiKLY", + "name": "Download", + "fill": "$default/default", + "cornerRadius": 6, + "padding": [ + 5, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "l6nNp", + "fill": "$foreground/foreground", + "content": "Download", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "XbBHf", + "name": "Delete", + "fill": "$default/default", + "cornerRadius": 6, + "padding": [ + 5, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "RAZ1s", + "fill": "$foreground/foreground", + "content": "Delete", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "xydTH", + "name": "Save", + "fill": "$default/default", + "cornerRadius": 6, + "padding": [ + 5, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "v1Uj7e", + "fill": "$foreground/foreground", + "content": "Save", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "peDru", + "name": "Monaco editor", + "width": "fill_container", + "height": "fill_container", + "fill": "#1E1E1E", + "gap": 22, + "padding": [ + 0, + 28 + ], + "children": [ + { + "type": "text", + "id": "BVuqs", + "fill": "$muted", + "content": "1\n2\n3\n4\n5\n6", + "lineHeight": 1.55, + "fontFamily": "$typography/font-mono", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "ETZHc", + "fill": "$foreground/foreground", + "content": "#Minecraft server properties\nmotd=Survival server\ndifficulty=normal\ngamemode=survival\nmax-players=20\nwhite-list=true", + "lineHeight": 1.55, + "fontFamily": "$typography/font-mono", + "fontSize": 13, + "fontWeight": "normal" + } + ] } ] } @@ -138272,6 +138512,91 @@ } } ] + }, + { + "id": "o1uEP", + "type": "ref", + "reusable": true, + "ref": "x3beP", + "name": "Gameplane/Dialog/Create ZIP", + "width": 448, + "x": 4240, + "y": 6274, + "descendants": { + "E7Whx": { + "content": "Create ZIP" + }, + "ABbjS": { + "enabled": false, + "width": 1, + "height": 0 + }, + "qzcst": { + "enabled": false, + "width": 1 + }, + "Nht52": { + "type": "frame", + "id": "USuhP", + "name": "Archive name", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 0, + 0, + 0 + ], + "children": [ + { + "type": "text", + "id": "KcxUD", + "fill": "$muted", + "content": "Archive filename (.zip)", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "id": "M5ATf5", + "type": "ref", + "ref": "D0cDM", + "name": "Archive filename", + "width": "fill_container", + "stroke": "$accent/accent", + "strokeWidth": 2, + "effect": [], + "descendants": { + "PJERm": { + "content": "" + } + } + } + ] + }, + "SBdeH": { + "height": 36, + "padding": [ + 0, + 16 + ] + }, + "REcmP": { + "fontSize": 14 + }, + "V7HYp": { + "height": 36, + "padding": [ + 0, + 16 + ] + }, + "TZ7Ef": { + "content": "Create", + "fontSize": 14 + } + } } ], "themes": { diff --git a/i18n/locales/en/app.json b/i18n/locales/en/app.json index af51c12f9..a7596069e 100644 --- a/i18n/locales/en/app.json +++ b/i18n/locales/en/app.json @@ -8099,6 +8099,14 @@ "defaultMessage": "Cancel", "description": "Files tab: button in the name prompt dialog that closes it without creating anything; keep it short" }, + "web.routes.tabs.files.close_file": { + "defaultMessage": "Close {path}", + "description": "Close a file tab after saving its edits." + }, + "web.routes.tabs.files.collapse": { + "defaultMessage": "Collapse {path}", + "description": "Hide a folder's children in the file tree." + }, "web.routes.tabs.files.config_yaml": { "defaultMessage": "config.yaml", "description": "Files tab: example placeholder in the new file name field; an example name, not a default" @@ -8107,6 +8115,10 @@ "defaultMessage": "Create", "description": "Files tab: confirm button of the new file and new folder prompts that creates the item; keep it short" }, + "web.routes.tabs.files.create_zip": { + "defaultMessage": "Create ZIP", + "description": "Create a ZIP of the selected files on the server." + }, "web.routes.tabs.files.delete": { "defaultMessage": "Delete", "description": "Files tab: red button that deletes the selected file or folder, and the confirm button of the delete dialog; keep it short" @@ -8127,6 +8139,14 @@ "defaultMessage": "This permanently removes the folder and everything inside it from the server's data volume. This action cannot be undone.", "description": "Files: body of the confirm dialog for deleting a folder" }, + "web.routes.tabs.files.delete_many_title": { + "defaultMessage": "Delete {count} items?", + "description": "Confirmation to delete multiple selected paths." + }, + "web.routes.tabs.files.delete_selected": { + "defaultMessage": "Delete selected", + "description": "Delete all selected files and folders." + }, "web.routes.tabs.files.discard_unsaved_changes": { "defaultMessage": "You have unsaved changes. Discard them and continue?", "description": "Files: confirm prompt shown before discarding unsaved file edits" @@ -8135,10 +8155,18 @@ "defaultMessage": "Download", "description": "Files tab: button that downloads the selected file to the user's computer; keep it short" }, + "web.routes.tabs.files.download_zip": { + "defaultMessage": "Download ZIP", + "description": "Download the selected paths as one ZIP archive." + }, "web.routes.tabs.files.empty_folder": { "defaultMessage": "Empty folder", "description": "Files tab: text shown in the file list when the current folder has no entries" }, + "web.routes.tabs.files.expand": { + "defaultMessage": "Expand {path}", + "description": "Show a folder's children in the file tree." + }, "web.routes.tabs.files.file_name": { "defaultMessage": "File name", "description": "Files tab: field label for the name of a new file in the new file prompt" @@ -8171,10 +8199,18 @@ "defaultMessage": "New folder", "description": "Files tab: button that opens the prompt to create a new folder, and the title of that prompt dialog; keep it short" }, + "web.routes.tabs.files.open_files": { + "defaultMessage": "Open files", + "description": "Accessible name for the open file tabs." + }, "web.routes.tabs.files.parent_directory": { "defaultMessage": "..", "description": "Files: row that opens the parent folder of the current folder" }, + "web.routes.tabs.files.partial_delete": { + "defaultMessage": "Deleted {count} of {total}. {errors}", + "description": "Reports how many selected paths were deleted and which paths failed." + }, "web.routes.tabs.files.path_separator": { "defaultMessage": "/", "description": "Files: slash drawn between breadcrumb segments" @@ -8195,6 +8231,14 @@ "defaultMessage": "Select a file to edit.", "description": "Files tab: empty-state text in the editor pane shown when no file is selected" }, + "web.routes.tabs.files.select_path": { + "defaultMessage": "Select {path}", + "description": "Select a path for file actions." + }, + "web.routes.tabs.files.selected_count": { + "defaultMessage": "{count} selected", + "description": "Number of checked file paths." + }, "web.routes.tabs.files.upload": { "defaultMessage": "Upload", "description": "Files tab: button that uploads a file to the current folder; shows a spinner while the upload runs; keep it short" @@ -8203,6 +8247,10 @@ "defaultMessage": "Working…", "description": "Files: label of the dialog submit button while the request is running" }, + "web.routes.tabs.files.zip_name": { + "defaultMessage": "Archive filename (.zip)", + "description": "Label for the name of a ZIP to save on the server." + }, "web.routes.tabs.logs.connected_waiting_for_output": { "defaultMessage": "Connected — waiting for output…", "description": "Logs: shown once the log stream is connected and no output has arrived yet" diff --git a/web/e2e/screenshots/slice2a.spec.ts b/web/e2e/screenshots/slice2a.spec.ts index 9f85525af..f8dddbe84 100644 --- a/web/e2e/screenshots/slice2a.spec.ts +++ b/web/e2e/screenshots/slice2a.spec.ts @@ -21,7 +21,7 @@ import { test, expect, type Page } from "@playwright/test"; // added by this task — additively — because no existing fixture covered // idle-armed, never-sleeps, or PVC-provisioning-failed. -import { capture } from "./capture"; +import { capture, captureLocator } from "./capture"; // Selects the enriched screenshot dataset before the app's first fetch. async function useScreenshotDataset(page: Page): Promise { @@ -176,11 +176,33 @@ test.describe("Slice 2a: Servers + core tabs (Desktop — 1440x900) @screenshots test("Burtr: Server Detail — Files", async ({ page }) => { await page.goto("/servers/mc-survival"); await clickTab(page, "Files"); - await expect(page.getByText("server.properties")).toBeVisible({ timeout: 10_000 }); - await page.waitForTimeout(200); + await page.getByRole("button", { name: "Expand /logs" }).click(); + await expect(page.getByRole("button", { name: "latest.log", exact: true })).toBeVisible(); + for (const name of ["eula.txt", "server.properties"]) { + await page.getByRole("checkbox", { name: "Select /" + name, exact: true }).check(); + await page.getByRole("button", { name, exact: true }).click(); + await expect(page.getByRole("button", { name: "Close /" + name, exact: true })).toBeVisible(); + } + await expect(page.getByText("2 selected", { exact: true })).toBeVisible(); + await expect(page.locator(".monaco-editor .view-line").first()).toContainText("#Minecraft server properties"); await capture(page, "Burtr"); }); + test("o1uEP: Create ZIP dialog", async ({ page }) => { + await page.addInitScript(() => { localStorage.setItem("gameplane-theme", "light"); }); + await page.goto("/servers/mc-survival"); + await clickTab(page, "Files"); + await page.getByRole("checkbox", { name: "Select /eula.txt", exact: true }).check(); + await page.getByRole("button", { name: "Create ZIP", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "Create ZIP", exact: true }); + const filename = dialog.getByRole("textbox", { name: "Archive filename (.zip)", exact: true }); + await expect(dialog.getByRole("button", { name: "Create", exact: true })).toBeDisabled(); + await filename.click(); + await captureLocator(page, "o1uEP", dialog); + await filename.fill("selected.zip"); + await expect(dialog.getByRole("button", { name: "Create", exact: true })).toBeEnabled(); + }); + test("dPP50: Server Detail — Players", async ({ page }) => { await page.goto("/servers/mc-survival"); await clickTab(page, "Players"); diff --git a/web/e2e/specs/fileWorkspace.spec.ts b/web/e2e/specs/fileWorkspace.spec.ts new file mode 100644 index 000000000..b9449e03a --- /dev/null +++ b/web/e2e/specs/fileWorkspace.spec.ts @@ -0,0 +1,27 @@ +import { expect, test } from "@playwright/test"; + +test("opens an unlisted file directly and autosaves it in the local editor", async ({ page }) => { + test.skip(process.env.GAMEPLANE_E2E_TARGET === "live", "Uses the mock server volume."); + await page.goto("/servers/alpha?cluster=local&ns=gameplane-games&file=%2Fconfig%2F%2Funlisted.txt"); + await expect(page.getByRole("tab", { name: "Files", exact: true })).toHaveAttribute("aria-selected", "true"); + const editor = page.locator(".monaco-editor"); + await expect(editor.locator(".view-line").first()).toContainText("# mock file body"); + const saved = page.waitForRequest(req => new URL(req.url()).pathname === "/servers/alpha/files/write"); + await editor.locator(".view-line").first().click(); + await page.keyboard.press("ControlOrMeta+a"); + await page.keyboard.insertText("motd=hello"); + const write = await saved; + expect(new URL(write.url()).searchParams.get("path")).toBe("/config/unlisted.txt"); + expect(new URL(write.url()).searchParams.get("namespace")).toBe("gameplane-games"); + expect(write.postData()).toBe("motd=hello"); + await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled(); + await page.setViewportSize({width: 390, height: 844}); + await page.getByRole("button", {name: "Back to files"}).click(); + const tabs = page.getByRole("navigation", {name: "Open files"}); + await tabs.getByRole("button", {name: "/config/unlisted.txt", exact: true}).click(); + await expect(editor.locator(".view-line").first()).toContainText("motd=hello"); + await page.setViewportSize({width: 1280, height: 720}); + await page.getByRole("button", { name: "Close /config/unlisted.txt" }).click(); + await expect(page.getByText("Select a file to edit.")).toBeVisible(); + expect(new URL(page.url()).searchParams.get("file")).toBe("/config/"); +}); diff --git a/web/scripts/compare-screenshots.mjs b/web/scripts/compare-screenshots.mjs index d42510491..b43aa9020 100755 --- a/web/scripts/compare-screenshots.mjs +++ b/web/scripts/compare-screenshots.mjs @@ -153,6 +153,8 @@ const SCALE_ALLOWLIST = { // alpha >= 250 bounding box. Re-measure it whenever the id is re-exported // or its shadow/size changes. const REFERENCE_CROP_ALLOWLIST = { + // Create ZIP dialog: the 448x196 panel excludes its outer shadow. + o1uEP: { left: 96, top: 72, width: 896, height: 392 }, // Backup Detail Drawer: 440x760 panel at 2x. The refreshed Pencil export's // shadow leaves 60 CSS px bleed left, 36 right, and 48 top and bottom; // its alpha >= 250 panel bounds are (120,96)-(1000,1616) => 880x1520. @@ -227,9 +229,9 @@ const SCALE_EPSILON = 0.0005; const DEFAULT_EXPECTED_SCREENS = [ // Slice 1: Shell + Login (7 screens) 'N1GkB', 'jmoi3', 'ljdA5', 'N13Xud', 'j24cXg', 'tooKB', 'SeizD', - // Slice 2a: Servers list + Server Detail core tabs (12 screens) + // Slice 2a: Servers list + Server Detail core tabs (13 screens) 'F9pUrx', 'EZFW0', 'Hy9r0', 'TE2jI', 'IzuY2', 'o4LH8W', - 'P08Uw', 'Xn5ns', 'kPmoo', 'FtdkI', 'Burtr', 'dPP50', + 'P08Uw', 'Xn5ns', 'kPmoo', 'FtdkI', 'Burtr', 'o1uEP', 'dPP50', ]; /** diff --git a/web/specs.md b/web/specs.md index 6f8520031..fa95ad6df 100644 --- a/web/specs.md +++ b/web/specs.md @@ -310,7 +310,9 @@ Six of the nine ServerDetail tabs are rebuilt in this slice: - Folder rows carry a trash button (on row hover or focus on desktop, always visible on mobile) that opens the same delete confirmation without opening the folder - The new file/folder name prompt explains a rejected name inline ("Names can't contain "/" or be "." or "..".") and marks the input `aria-invalid`; Create stays disabled - Monaco editor unchanged - - File drafts bind to the selected path, resource target and selection generation. Selection clears content immediately and Save stays disabled until that file has loaded. Late reads and save completions cannot replace another selection's content or baseline, including reopening the same path. Saves capture their target, path, body and directory at click time; edits typed during a save remain dirty against the saved body. Changing server/namespace/cluster/UID starts a fresh file browser. + - The tree expands folders on demand. A server URL with a `file` query opens the path directly, even if a directory listing is truncated. A trailing slash browses that directory. + - Open files have separate tabs and drafts scoped by cluster, namespace, server name, UID and path. Edits autosave after a pause, with one write at a time per file. A failed write retains the draft for retry within the current session. Closing a dirty tab waits for saving. Deletion waits for outstanding writes and invalidates pending reads of removed paths. If deletion fails, dirty drafts resume autosaving. Browser navigation controls the visible mobile pane. + - Check one or more paths to download a ZIP, create a ZIP in the current directory, or confirm deletion. The file manager identifies failed paths and keeps them selected. - New file uses POST `/files/create` with the encoded path, namespace/cluster scope, credentials and CSRF headers. The agent atomically refuses existing entries with 409; a conflict or unsupported older agent/gateway keeps the prompt open, shows the error and does not select a file or invalidate the list. Never fall back to `/files/write`; editor saves and uploads retain overwrite behavior. 6. **Players** (`web/src/routes/tabs/Players.tsx`, 386 lines) diff --git a/web/src/lib/endpoints.ts b/web/src/lib/endpoints.ts index 991a27686..b9692df86 100644 --- a/web/src/lib/endpoints.ts +++ b/web/src/lib/endpoints.ts @@ -657,7 +657,7 @@ export const ModuleBuilder = { type RequestClient = Pick, "api" | "url" | "raw" | "Captures" | "Shares">; -function makeResourceEndpoints(request: RequestClient) { +function makeResourceEndpoints(request: RequestClient, expectedUID?: string) { const api = request.api; const withCluster = (path: string, cluster?: string) => request.url(cluster ? withClusterParam(path, cluster) : path); const fetch = request.raw; @@ -934,7 +934,8 @@ function makeResourceEndpoints(request: RequestClient) { }; async function filesFetch(input: string, init: RequestInit = {}): Promise { - const res = await fetch(withCluster(input), { credentials: "include", ...init }); + const headers = { ...init.headers, ...(expectedUID && { "X-Gameplane-Server-UID": expectedUID }) }; + const res = await fetch(withCluster(input), { credentials: "include", ...init, headers }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new APIError(res.status, text); @@ -944,7 +945,9 @@ function makeResourceEndpoints(request: RequestClient) { const Files = { list: (server: string, path: string, ns?: string) => - api(withNS(`${filesBase(server)}/list?path=${encodeURIComponent(path)}`, ns)), + api(withNS(`${filesBase(server)}/list?path=${encodeURIComponent(path)}`, ns), { + headers: expectedUID ? { "X-Gameplane-Server-UID": expectedUID } : undefined, + }), read: async (server: string, path: string, ns?: string): Promise => { const res = await filesFetch( withNS(`${filesBase(server)}/read?path=${encodeURIComponent(path)}`, ns), @@ -1007,8 +1010,15 @@ function makeResourceEndpoints(request: RequestClient) { body: fd, }); }, + archive: async (server: string, paths: string[], dest?: string, ns?: string): Promise => { + const query = new URLSearchParams(paths.map((path) => ["path", path])); + if (dest) query.set("dest", dest); + const res = await filesFetch(withNS(`${filesBase(server)}/archive?${query}`, ns), + dest ? { method: "POST", headers: csrfHeaders() } : {}); + if (!dest) return res.blob(); + }, downloadURL: (server: string, path: string, ns?: string) => - withCluster(withNS(`${filesBase(server)}/download?path=${encodeURIComponent(path)}`, ns)), + withCluster(withNS(`${filesBase(server)}/download?path=${encodeURIComponent(path)}${expectedUID ? "&expectedUID=" + encodeURIComponent(expectedUID) : ""}`, ns)), }; const Logs = { @@ -1028,10 +1038,10 @@ function makeResourceEndpoints(request: RequestClient) { } /** Capture cluster/namespace once before starting any asynchronous operation. */ -export function createResourceClient(scope: ResourceScope, signal?: AbortSignal): ResourceClient { +export function createResourceClient(scope: ResourceScope & {uid?: string}, signal?: AbortSignal): ResourceClient { const captured = Object.freeze({ ...scope }); return { - ...makeResourceEndpoints(createRequestClient(captured, signal)), + ...makeResourceEndpoints(createRequestClient(captured, signal), captured.uid), withSignal: (nextSignal: AbortSignal) => createResourceClient(captured, nextSignal), }; } diff --git a/web/src/lib/fileDrafts.test.ts b/web/src/lib/fileDrafts.test.ts new file mode 100644 index 000000000..2e59a09cd --- /dev/null +++ b/web/src/lib/fileDrafts.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from "vitest"; +import { FileDrafts } from "./fileDrafts"; + +describe("file drafts", () => { + it("retains a rejected draft for another tab and retries the same text", async () => { + const write = vi.fn().mockRejectedValueOnce(new Error("disk full")).mockResolvedValue(undefined); + const drafts = new FileDrafts(write); + drafts.open("/first", "before"); + drafts.edit("/first", "after"); + expect(await drafts.save("/first")).toBe(false); + drafts.open("/second", "other"); + drafts.open("/first", "before"); + expect(drafts.get("/first")).toMatchObject({ text: "after", saved: "before", error: "disk full" }); + expect(await drafts.save("/first")).toBe(true); + expect(write.mock.calls).toEqual([["/first", "after"], ["/first", "after"]]); + }); + + it("orders writes and blocks descendant writes while a folder is removed", async () => { + let finish!: () => void; + const write = vi.fn().mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })).mockResolvedValue(undefined); + const drafts = new FileDrafts(write); + drafts.open("/dir/file", "initial"); + drafts.edit("/dir/file", "first"); + const pending = drafts.save("/dir/file"); + drafts.edit("/dir/file", "second"); + const deletion = vi.fn().mockResolvedValue(undefined); + const remove = drafts.remove("/dir", deletion); + expect(deletion).not.toHaveBeenCalled(); + finish(); + await pending; + await remove; + expect(deletion).toHaveBeenCalledOnce(); + expect(write.mock.calls).toEqual([["/dir/file", "first"]]); + expect(drafts.get("/dir/file")).toBeUndefined(); + }); +}); + +it("resumes a debounced save after deleting the containing folder fails", async () => { + vi.useFakeTimers(); + try { + const write = vi.fn().mockResolvedValue(undefined); + const drafts = new FileDrafts(write); + drafts.open("/dir/file", "before"); + drafts.edit("/dir/file", "edited"); + await expect(drafts.remove("/dir", async () => { throw new Error("busy"); })).rejects.toThrow("busy"); + await vi.advanceTimersByTimeAsync(1500); + expect(write).toHaveBeenCalledWith("/dir/file", "edited"); + } finally { + vi.useRealTimers(); + } +}); + +it("cancels outstanding reads of files removed in a batch", async () => { + const drafts = new FileDrafts(vi.fn()); + const file = new AbortController(); + const other = new AbortController(); + const release = drafts.trackRead("/dir/file", file); + const releaseOther = drafts.trackRead("/other/file", other); + await drafts.remove("/dir", async () => {}); + expect(file.signal.aborted).toBe(true); + expect(other.signal.aborted).toBe(false); + release(); + releaseOther(); +}); diff --git a/web/src/lib/fileDrafts.ts b/web/src/lib/fileDrafts.ts new file mode 100644 index 000000000..b2d1b1691 --- /dev/null +++ b/web/src/lib/fileDrafts.ts @@ -0,0 +1,146 @@ +interface Draft { + text: string; + saved: string; + error?: string; + deleting?: boolean; + pending?: Promise; + timer?: ReturnType; +} + +// Each workspace belongs to one authorized server incarnation and query client. +// Draft contents stay in this browser session and never enter persistent storage. +export class FileDrafts { + private readonly files = new Map(); + private readonly listeners = new Set<() => void>(); + private readonly reads = new Map(); + private readonly removing = new Set(); + private revision = 0; + private enabled = true; + + constructor(private readonly write: (path: string, text: string) => Promise) {} + + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { this.listeners.delete(listener); }; + }; + snapshot = () => this.revision; + private changed() { + this.revision++; + this.listeners.forEach((listener) => listener()); + } + + paths() { return Array.from(this.files.keys()); } + get(path: string) { return this.files.get(path); } + private isRemoving(path: string) { + return [...this.removing].some((root) => path === root || path.startsWith(root + "/")); + } + + open(path: string, text: string) { + if (!this.files.has(path)) { + this.files.set(path, { text, saved: text, deleting: this.isRemoving(path) }); + this.changed(); + } + } + + trackRead(path: string, controller: AbortController) { + this.reads.set(controller, path); + return () => { this.reads.delete(controller); }; + } + + setEnabled(enabled: boolean) { + this.enabled = enabled; + for (const [path, draft] of this.files) { + clearTimeout(draft.timer); + if (enabled && !draft.error && draft.text !== draft.saved) this.schedule(path, draft); + } + } + + private schedule(path: string, draft: Draft) { + clearTimeout(draft.timer); + draft.timer = setTimeout(() => { void this.save(path); }, 1200); + } + + edit(path: string, text: string) { + const draft = this.get(path); + if (!draft || draft.deleting || !this.enabled || draft.text === text) return; + draft.text = text; + draft.error = undefined; + this.schedule(path, draft); + this.changed(); + } + + save(path: string): Promise { + const draft = this.get(path); + if (!draft || draft.deleting || !this.enabled) return Promise.resolve(false); + clearTimeout(draft.timer); + if (!draft.pending) { + draft.pending = this.flush(path, draft).finally(() => { + draft.pending = undefined; + this.changed(); + }); + this.changed(); + } + return draft.pending; + } + + private async flush(path: string, draft: Draft) { + draft.error = undefined; + try { + while (this.enabled && !draft.deleting && draft.saved !== draft.text) { + const body = draft.text; + await this.write(path, body); + draft.saved = body; + } + return draft.text === draft.saved; + } catch (err) { + clearTimeout(draft.timer); + draft.error = (err as Error).message; + return false; + } + } + + async close(path: string) { + const draft = this.get(path); + if (!draft || draft.deleting) return false; + if ((draft.pending || draft.text !== draft.saved) && !await this.save(path)) return false; + return draft.text === draft.saved && this.discard(path); + } + + discard(path: string) { + const draft = this.get(path); + if (!draft || draft.pending || draft.deleting) return false; + clearTimeout(draft.timer); + this.files.delete(path); + this.changed(); + return true; + } + + async remove(path: string, operation: () => Promise) { + const matches = (key: string) => key === path || key.startsWith(path + "/"); + this.removing.add(path); + const affected = [...this.files].filter(([key]) => matches(key)); + for (const [, draft] of affected) { + draft.deleting = true; + clearTimeout(draft.timer); + } + this.changed(); + try { + await Promise.all(affected.map(([, draft]) => draft.pending)); + await operation(); + for (const [controller, key] of this.reads) { + if (matches(key)) controller.abort(); + } + for (const key of this.files.keys()) { + if (matches(key)) this.files.delete(key); + } + } finally { + this.removing.delete(path); + for (const [key, draft] of this.files) { + if (!matches(key)) continue; + draft.deleting = this.isRemoving(key); + if (!draft.deleting && this.enabled && !draft.error && draft.saved !== draft.text) this.schedule(key, draft); + } + this.changed(); + } + } +} diff --git a/web/src/router/tree.tsx b/web/src/router/tree.tsx index 11f9f354b..737d337ad 100644 --- a/web/src/router/tree.tsx +++ b/web/src/router/tree.tsx @@ -64,11 +64,13 @@ const serverDetailRoute = new Route({ getParentRoute: () => appLayoutRoute, path: "/servers/$name", component: ServerDetailPage, - validateSearch: (search: Record): { ns?: string; cluster?: string } => { + validateSearch: (search: Record): { ns?: string; cluster?: string; file?: string } => { for (const key of ["cluster", "ns"] as const) { if (search[key] !== undefined && (typeof search[key] !== "string" || !search[key])) throw new Error(`Invalid server ${key}`); } - return { ns: search.ns as string | undefined, cluster: search.cluster as string | undefined }; + const file = search.file; + if (file !== undefined && (typeof file !== "string" || !file.startsWith("/") || file.split("/").some(part => part === "." || part === ".."))) throw new Error("Invalid file path"); + return { ns: search.ns as string | undefined, cluster: search.cluster as string | undefined, file: file as string | undefined }; }, }); diff --git a/web/src/routes/ServerDetail.tsx b/web/src/routes/ServerDetail.tsx index 4ed7961fd..645b7f0cd 100644 --- a/web/src/routes/ServerDetail.tsx +++ b/web/src/routes/ServerDetail.tsx @@ -79,11 +79,11 @@ const tabs: Array<{ key: TabKey; label: MessageDescriptor }> = [ export function ServerDetailPage() { const { name } = useParams({ from: "/app-layout/servers/$name" }); - const { ns, cluster = "local" } = useSearch({ from: "/app-layout/servers/$name" }); - return ; + const { ns, cluster = "local", file } = useSearch({ from: "/app-layout/servers/$name" }); + return ; } -function ResolveServerDetail({ target }: { target: ResourceTarget }) { +function ResolveServerDetail({ target, file }: { target: ResourceTarget; file?: string }) { const intl = useOptionalIntl(); const navigate = useNavigate(); const server = useQuery({ @@ -100,9 +100,10 @@ function ResolveServerDetail({ target }: { target: ResourceTarget }) { }); useEffect(() => { if (!target.namespace && server.data?.metadata.namespace) { - void navigate({ ...serverLink({ cluster: target.cluster, name: target.name, namespace: server.data.metadata.namespace }), replace: true }); + const link = serverLink({ cluster: target.cluster, name: target.name, namespace: server.data.metadata.namespace }); + void navigate({ ...link, search: { ...link.search, file }, replace: true }); } - }, [target.cluster, target.namespace, target.name, server.data?.metadata.namespace, navigate]); + }, [target.cluster, target.namespace, target.name, server.data?.metadata.namespace, navigate, file]); const retry = () => { void server.refetch(); void access.refetch(); }; if (server.error || access.error) return
; if (!server.data || !access.data) return
; @@ -112,12 +113,12 @@ function ResolveServerDetail({ target }: { target: ResourceTarget }) { } return ( - + ); } -function ServerDetailView({ lookupTarget }: { lookupTarget: ResourceTarget }) { +function ServerDetailView({ lookupTarget, file }: { lookupTarget: ResourceTarget; file?: string }) { const intl = useOptionalIntl(); const target = useResourceTarget(lookupTarget); const clusterId = target.cluster; @@ -126,10 +127,20 @@ function ServerDetailView({ lookupTarget }: { lookupTarget: ResourceTarget }) { const client = useResourceClient(); const { Servers } = client; const access = useResourceAccess(); - const [tab, setTab] = useState("overview"); + const [localTab, setTab] = useState("overview"); + const tab = file !== undefined ? "files" : localTab; const [settingsDirty, setSettingsDirty] = useState(false); const qc = useQueryClient(); const nav = useNavigate(); + function navigateFile(path?: string) { + const link = serverLink(target); + void nav({ ...link, search: { ...link.search, file: path } }); + } + function selectTab(next: TabKey) { + setTab(next); + if (next === "files" && file === undefined) navigateFile("/"); + else if (next !== "files" && file !== undefined) navigateFile(); + } const { data: me } = useMe(); const { data: gs } = useQuery({ @@ -315,7 +326,7 @@ function ServerDetailView({ lookupTarget }: { lookupTarget: ResourceTarget }) { {intl.formatMessage({ id: "web.routes.server_detail.start", defaultMessage: "Start", description: "Server detail: header button that starts a stopped server; keep it short" })} )} {consoleAvailable && ( - )} {gs && ( @@ -336,7 +347,7 @@ function ServerDetailView({ lookupTarget }: { lookupTarget: ResourceTarget }) {