Loading cmd/felis/api.go +6 −1 Changes for cmd/felis/api.go: 6 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -342,11 +342,15 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { // orphaned (the index is in memory), so the stage starts empty. var files api.FileEditor var fileStage *fileedit.Stage var fileBrowser *fileedit.Browser if felisImage != "" { fcfg := fileEditConfig(cfg, felisImage) fcfg.ResolveWorld = worldResolver fileBrowser = &fileedit.Browser{BaseURL: internalAPIBaseURL()} runner := fileedit.NewK8sRunner(clientset) runner.Browser = fileBrowser files = &fileedit.Editor{ Runner: fileedit.NewK8sRunner(clientset), Runner: runner, Config: fcfg, } fileStage = &fileedit.Stage{Dir: fileStagingDir()} Loading Loading @@ -416,6 +420,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { RestoreChains: jobStatus, Files: files, FileStage: fileStage, FileBrowser: fileBrowser, // The file Job fetches an upload from here; it runs in the minecraft // namespace, where the internal face is reachable like it is for the login // gate. Loading cmd/felis/files.go +11 −0 Changes for cmd/felis/files.go: 11 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -40,6 +40,7 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("files", flag.ContinueOnError) fs.SetOutput(stderr) op := fs.String("op", "", "operation: list, read, write, mkdir, delete, rename, upload or unzip") browseURL := fs.String("browse-url", "", "internal command channel for a read-only file browser") path := fs.String("path", "", "path to operate on, relative to the world root (empty = the root itself)") worldsRoot := fs.String("worlds-root", "/data", "mount path of the world PVC; every path resolves under it") expect := fs.String("expect-sha256", "", "write only: refuse unless the file's current SHA-256 (hex) is this") Loading @@ -53,6 +54,16 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int { return 2 } if *browseURL != "" { limitHeapToCgroup() ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() if err := fileedit.Browse(ctx, *worldsRoot, *browseURL, os.Getenv(fileedit.BrowserTokenEnv)); err != nil { fmt.Fprintf(stderr, "felis files: %v\n", err) return 1 } return 0 } if *op == "" { fmt.Fprintln(stderr, "felis files: --op is required") return 2 Loading docs/openapi.yaml +50 −0 Changes for docs/openapi.yaml: 50 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -1625,6 +1625,56 @@ paths: '404': $ref: '#/components/responses/NotFound' /api/v1/internal/file-browser/{id}: post: tags: [files] operationId: internalFileBrowser summary: Exchange a read-only file Job's result for its next command. description: >- Internal face only. A random bearer token scopes the worker to one world and a four-minute session; idle workers exit after 45 seconds. The first request sends an empty object. Later requests return the previous command's id and result or error. This endpoint dispatches only reads already authorized by the external file API. x-felis-face: [internal] x-felis-tier: public security: [] parameters: - { name: id, in: path, required: true, schema: { type: string } } - { name: Authorization, in: header, required: true, schema: { type: string } } requestBody: required: true content: application/json: schema: type: object properties: id: { type: string } result: { type: object } error: { type: string } responses: '200': description: The next authorized read command. content: application/json: schema: type: object required: [id, op, path] properties: id: { type: string } op: { type: string, enum: [list, read] } path: { type: string } '204': $ref: '#/components/responses/NoContent' '400': $ref: '#/components/responses/BadRequest' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '503': $ref: '#/components/responses/ServiceUnavailable' /api/v1/internal/exports/{id}: put: tags: [backups] Loading docs/troubleshooting.md +12 −2 Changes for docs/troubleshooting.md: 12 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -3170,8 +3170,18 @@ for 10 seconds (the Free plan's limits). ## 18. Server files: a change or an upload is refused The panel's Files page is for the server's owner or an admin, and only while the server is fully stopped. Each call runs a one-shot `felis files` Job in the `minecraft` namespace, labelled `app.kubernetes.io/managed-by=felis-files` and the server is fully stopped. Reads reuse a read-only `felis files` Job for the same world, avoiding Pod startup for every folder and file. The worker pulls commands from the existing internal API with a random, world-scoped token; each request still passes the owner/admin and stopped gates. It exits after 45 idle seconds or four minutes total; at most four workers exist per API process, with one-shot reads used at capacity. No file contents are cached. The panel caches directory listings for 30 seconds; Refresh, a write, upload or restore invalidates them. The text editor highlights common config formats and preserves CRLF; binary and oversized files offer download instead. Changes still run one-shot Jobs in the `minecraft` namespace, labelled `app.kubernetes.io/managed-by=felis-files` and `felis.lolicon.best/files-mode=<list|read|write|mkdir|delete|rename|upload|unzip>`. A listing or a read holds nothing. Every change (a save, a new file or folder, a rename, a delete, an upload, an unzip) holds the world for its Job (§3b), so a wake or a Loading internal/api/api.go +3 −0 Changes for internal/api/api.go: 3 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -99,6 +99,7 @@ type API struct { // InternalBaseURL is where that Job reaches felis-api's internal face to do so // (handleUploadFile). Uploads report 503 unless both are set. FileStage *fileedit.Stage FileBrowser *fileedit.Browser InternalBaseURL string // Exporter starts the Job behind a world or backup download (exports.go), Loading Loading @@ -476,6 +477,8 @@ func (a *API) internalAPIRoutes() []apiRoute { // with the upload is the check (handlers_files.go). {Method: "GET", Pattern: "/api/v1/internal/file-uploads/{id}", Public: true, h: a.handleInternalFileUpload}, {Method: "DELETE", Pattern: "/api/v1/internal/file-uploads/{id}", Public: true, h: a.handleInternalFileUploadLanded}, // Read-only file Jobs pull commands with their own scoped bearer token. {Method: "POST", Pattern: "/api/v1/internal/file-browser/{id}", Public: true, h: a.handleInternalFileBrowser}, // An export Job's archive, held open until the owner's browser downloads // it. Public for the same reason as file uploads: the Job holds no service Loading Loading
cmd/felis/api.go +6 −1 Changes for cmd/felis/api.go: 6 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -342,11 +342,15 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { // orphaned (the index is in memory), so the stage starts empty. var files api.FileEditor var fileStage *fileedit.Stage var fileBrowser *fileedit.Browser if felisImage != "" { fcfg := fileEditConfig(cfg, felisImage) fcfg.ResolveWorld = worldResolver fileBrowser = &fileedit.Browser{BaseURL: internalAPIBaseURL()} runner := fileedit.NewK8sRunner(clientset) runner.Browser = fileBrowser files = &fileedit.Editor{ Runner: fileedit.NewK8sRunner(clientset), Runner: runner, Config: fcfg, } fileStage = &fileedit.Stage{Dir: fileStagingDir()} Loading Loading @@ -416,6 +420,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { RestoreChains: jobStatus, Files: files, FileStage: fileStage, FileBrowser: fileBrowser, // The file Job fetches an upload from here; it runs in the minecraft // namespace, where the internal face is reachable like it is for the login // gate. Loading
cmd/felis/files.go +11 −0 Changes for cmd/felis/files.go: 11 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -40,6 +40,7 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("files", flag.ContinueOnError) fs.SetOutput(stderr) op := fs.String("op", "", "operation: list, read, write, mkdir, delete, rename, upload or unzip") browseURL := fs.String("browse-url", "", "internal command channel for a read-only file browser") path := fs.String("path", "", "path to operate on, relative to the world root (empty = the root itself)") worldsRoot := fs.String("worlds-root", "/data", "mount path of the world PVC; every path resolves under it") expect := fs.String("expect-sha256", "", "write only: refuse unless the file's current SHA-256 (hex) is this") Loading @@ -53,6 +54,16 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int { return 2 } if *browseURL != "" { limitHeapToCgroup() ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() if err := fileedit.Browse(ctx, *worldsRoot, *browseURL, os.Getenv(fileedit.BrowserTokenEnv)); err != nil { fmt.Fprintf(stderr, "felis files: %v\n", err) return 1 } return 0 } if *op == "" { fmt.Fprintln(stderr, "felis files: --op is required") return 2 Loading
docs/openapi.yaml +50 −0 Changes for docs/openapi.yaml: 50 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -1625,6 +1625,56 @@ paths: '404': $ref: '#/components/responses/NotFound' /api/v1/internal/file-browser/{id}: post: tags: [files] operationId: internalFileBrowser summary: Exchange a read-only file Job's result for its next command. description: >- Internal face only. A random bearer token scopes the worker to one world and a four-minute session; idle workers exit after 45 seconds. The first request sends an empty object. Later requests return the previous command's id and result or error. This endpoint dispatches only reads already authorized by the external file API. x-felis-face: [internal] x-felis-tier: public security: [] parameters: - { name: id, in: path, required: true, schema: { type: string } } - { name: Authorization, in: header, required: true, schema: { type: string } } requestBody: required: true content: application/json: schema: type: object properties: id: { type: string } result: { type: object } error: { type: string } responses: '200': description: The next authorized read command. content: application/json: schema: type: object required: [id, op, path] properties: id: { type: string } op: { type: string, enum: [list, read] } path: { type: string } '204': $ref: '#/components/responses/NoContent' '400': $ref: '#/components/responses/BadRequest' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '503': $ref: '#/components/responses/ServiceUnavailable' /api/v1/internal/exports/{id}: put: tags: [backups] Loading
docs/troubleshooting.md +12 −2 Changes for docs/troubleshooting.md: 12 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -3170,8 +3170,18 @@ for 10 seconds (the Free plan's limits). ## 18. Server files: a change or an upload is refused The panel's Files page is for the server's owner or an admin, and only while the server is fully stopped. Each call runs a one-shot `felis files` Job in the `minecraft` namespace, labelled `app.kubernetes.io/managed-by=felis-files` and the server is fully stopped. Reads reuse a read-only `felis files` Job for the same world, avoiding Pod startup for every folder and file. The worker pulls commands from the existing internal API with a random, world-scoped token; each request still passes the owner/admin and stopped gates. It exits after 45 idle seconds or four minutes total; at most four workers exist per API process, with one-shot reads used at capacity. No file contents are cached. The panel caches directory listings for 30 seconds; Refresh, a write, upload or restore invalidates them. The text editor highlights common config formats and preserves CRLF; binary and oversized files offer download instead. Changes still run one-shot Jobs in the `minecraft` namespace, labelled `app.kubernetes.io/managed-by=felis-files` and `felis.lolicon.best/files-mode=<list|read|write|mkdir|delete|rename|upload|unzip>`. A listing or a read holds nothing. Every change (a save, a new file or folder, a rename, a delete, an upload, an unzip) holds the world for its Job (§3b), so a wake or a Loading
internal/api/api.go +3 −0 Changes for internal/api/api.go: 3 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -99,6 +99,7 @@ type API struct { // InternalBaseURL is where that Job reaches felis-api's internal face to do so // (handleUploadFile). Uploads report 503 unless both are set. FileStage *fileedit.Stage FileBrowser *fileedit.Browser InternalBaseURL string // Exporter starts the Job behind a world or backup download (exports.go), Loading Loading @@ -476,6 +477,8 @@ func (a *API) internalAPIRoutes() []apiRoute { // with the upload is the check (handlers_files.go). {Method: "GET", Pattern: "/api/v1/internal/file-uploads/{id}", Public: true, h: a.handleInternalFileUpload}, {Method: "DELETE", Pattern: "/api/v1/internal/file-uploads/{id}", Public: true, h: a.handleInternalFileUploadLanded}, // Read-only file Jobs pull commands with their own scoped bearer token. {Method: "POST", Pattern: "/api/v1/internal/file-browser/{id}", Public: true, h: a.handleInternalFileBrowser}, // An export Job's archive, held open until the owner's browser downloads // it. Public for the same reason as file uploads: the Job holds no service Loading