From 33d3a13e74b39fc75c2189ac297881e1f7ace96f Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Mon, 24 Aug 2026 12:56:55 +0800 Subject: [PATCH] Add server file manager workflow --- platform/api/resource_handlers.go | 219 ++++++++ platform/api/resource_handlers_test.go | 46 +- platform/api/routes.md | 9 +- platform/domain/resources.go | 207 ++++++++ platform/dto/job_channel.go | 29 ++ platform/dto/resources.go | 171 +++++++ platform/protocol/run-contracts.md | 16 +- platform/service/job_channel.go | 4 + platform/service/resources.go | 30 +- platform/service/server_files.go | 508 +++++++++++++++++++ platform/validator/resources.go | 116 ++++- platform_web/api/client.test.ts | 56 +- platform_web/api/client.ts | 64 +++ platform_web/api/contracts.md | 3 +- platform_web/api/types.ts | 108 +++- platform_web/contracts/workspace.ts | 3 +- platform_web/pages/ServerDetailPage.test.tsx | 4 + platform_web/pages/ServerDetailPage.tsx | 327 +++++++++++- platform_web/theme/base.css | 21 + platform_web/utils/serverFileTransfer.ts | 42 ++ 20 files changed, 1961 insertions(+), 22 deletions(-) create mode 100644 platform/service/server_files.go create mode 100644 platform_web/utils/serverFileTransfer.ts diff --git a/platform/api/resource_handlers.go b/platform/api/resource_handlers.go index 5105aef..af28cb9 100644 --- a/platform/api/resource_handlers.go +++ b/platform/api/resource_handlers.go @@ -1,6 +1,7 @@ package api import ( + "io" "net/http" "strconv" "strings" @@ -92,6 +93,14 @@ func (h *coreHandlers) register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/install", h.serverDependenciesInstall) mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies) mux.HandleFunc("/api/v1/server-instances/{id}/logs/events", h.serverLogEvents) + mux.HandleFunc("/api/v1/server-instances/{id}/files/workspace", h.serverFilesWorkspace) + mux.HandleFunc("/api/v1/server-instances/{id}/files/list", h.serverFilesList) + mux.HandleFunc("/api/v1/server-instances/{id}/files/refresh", h.serverFilesRefresh) + mux.HandleFunc("/api/v1/server-instances/{id}/files/read-snapshot", h.serverFilesReadSnapshot) + mux.HandleFunc("/api/v1/server-instances/{id}/files/read", h.serverFilesRead) + mux.HandleFunc("/api/v1/server-instances/{id}/files/write", h.serverFilesWrite) + mux.HandleFunc("/api/v1/server-instances/{id}/files/upload", h.serverFilesUpload) + mux.HandleFunc("/api/v1/server-instances/{id}/files/download", h.serverFilesDownload) mux.HandleFunc("/api/v1/server-instances/{id}/rcon/commands", h.sourceRCONCommands) mux.HandleFunc("/api/v1/server-instances/{id}/administrators/candidates", h.serverAdministratorCandidates) mux.HandleFunc("/api/v1/server-instances/{id}/administrators", h.serverAdministrators) @@ -109,6 +118,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/run/jobs/source-rcon-input", h.requireRunSignature(h.runSourceRCONInput)) mux.HandleFunc("/api/v1/run/jobs/update-input", h.requireRunSignature(h.runJobUpdateInput)) mux.HandleFunc("/api/v1/run/jobs/update-chunk", h.requireRunSignature(h.runJobUpdateChunk)) + mux.HandleFunc("/api/v1/run/files/input-chunk", h.requireRunSignature(h.runFileInputChunk)) mux.HandleFunc("/api/v1/run/jobs/update-health", h.requireRunSignature(h.runJobUpdateHealth)) mux.HandleFunc("/api/v1/run/jobs/client-manager-input", h.requireRunSignature(h.runClientManagerLifecycleInput)) mux.HandleFunc("/api/v1/run/jobs/client-manager-chunk", h.requireRunSignature(h.runClientManagerLifecycleChunk)) @@ -1278,6 +1288,196 @@ func (h *coreHandlers) serverInstanceConfigApprove(w http.ResponseWriter, r *htt writeJSON(w, http.StatusAccepted, dto.ServerConfigWriteDispatchFromDomain(dispatch)) } +// serverFilesWorkspace godoc +// @Summary Read server file workspace +// @Description Returns plugin-declared logical directories and transfer policy without exposing host paths. +// @Tags server-files +// @Produce json +// @Param id path string true "Server instance ID" +// @Success 200 {object} dto.ServerFileWorkspaceResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 403 {object} dto.ErrorResponse +// @Failure 404 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/server-instances/{id}/files/workspace [get] +func (h *coreHandlers) serverFilesWorkspace(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + workspace, err := h.core.GetServerFileWorkspaceForSession(bearerToken(r), r.PathValue("id")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.ServerFileWorkspaceFromDomain(workspace)) +} + +func (h *coreHandlers) serverFilesList(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + directoryKey, err := h.serverFileDirectoryKey(r, r.URL.Query().Get("directoryKey")) + if err != nil { + writeServiceError(w, err) + return + } + result, err := h.core.ListServerFilesForSession(bearerToken(r), domain.ServerFileListRequest{ServerInstanceID: r.PathValue("id"), DirectoryKey: directoryKey, Path: r.URL.Query().Get("path"), Query: r.URL.Query().Get("query"), Recursive: r.URL.Query().Get("recursive") == "true"}) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.ServerFileListFromDomain(result)) +} + +func (h *coreHandlers) serverFilesRefresh(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.ServerFileListRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + request.DirectoryKey, err = h.serverFileDirectoryKey(r, request.DirectoryKey) + if err != nil { + writeServiceError(w, err) + return + } + result, err := h.core.RefreshServerFileListForSession(bearerToken(r), request.ToDomain(r.PathValue("id"))) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusAccepted, dto.ServerFileListFromDomain(result)) +} + +func (h *coreHandlers) serverFilesReadSnapshot(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + snapshot, err := h.core.GetDeclaredFileReadSnapshotForSession(bearerToken(r), r.PathValue("id"), r.URL.Query().Get("key")) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.DeclaredFileReadSnapshotFromDomain(snapshot)) +} + +func (h *coreHandlers) serverFilesRead(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.ServerFileReadRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.ReadServerFileForSession(bearerToken(r), request.ToDomain(r.PathValue("id"))) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusAccepted, dto.FileOperationDispatchFromDomain(result)) +} + +func (h *coreHandlers) serverFilesWrite(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.ServerFileWriteRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.WriteServerFileForSession(bearerToken(r), request.ToDomain(r.PathValue("id"))) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusAccepted, dto.FileOperationDispatchFromDomain(result)) +} + +func (h *coreHandlers) serverFilesUpload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + upload, err := h.decodeServerFileUpload(r) + if err != nil { + writeServiceError(w, err) + return + } + result, err := h.core.UploadServerFileForSession(bearerToken(r), upload) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusAccepted, dto.ServerFileUploadFromDomain(result)) +} + +func (h *coreHandlers) serverFilesDownload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.ServerFileDownloadRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.PrepareServerFileDownloadForSession(bearerToken(r), request.ToDomain(r.PathValue("id"))) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusAccepted, dto.ServerFileDownloadFromDomain(result)) +} + +func (h *coreHandlers) serverFileDirectoryKey(r *http.Request, requested string) (string, error) { + requested = strings.TrimSpace(requested) + if requested != "" { + return requested, nil + } + workspace, err := h.core.GetServerFileWorkspaceForSession(bearerToken(r), r.PathValue("id")) + if err != nil { + return "", err + } + return workspace.DefaultDirectoryKey, nil +} + +func (h *coreHandlers) decodeServerFileUpload(r *http.Request) (domain.ServerFileUploadRequest, error) { + if err := r.ParseMultipartForm(8 * 1024 * 1024); err != nil { + return domain.ServerFileUploadRequest{}, validator.ValidationError{Violations: []string{"multipart upload is invalid"}} + } + file, header, err := r.FormFile("file") + if err != nil { + return domain.ServerFileUploadRequest{}, validator.ValidationError{Violations: []string{"file is required"}} + } + defer file.Close() + payload, err := io.ReadAll(io.LimitReader(file, validator.MaxArtifactBytes+1)) + if err != nil { + return domain.ServerFileUploadRequest{}, err + } + if int64(len(payload)) > validator.MaxArtifactBytes { + return domain.ServerFileUploadRequest{}, validator.ValidationError{Violations: []string{"payload is too large"}} + } + checksum := strings.TrimSpace(r.FormValue("checksum")) + if checksum == "" { + checksum = validator.BytesChecksum(payload) + } + filename := strings.TrimSpace(r.FormValue("filename")) + if filename == "" && header != nil { + filename = header.Filename + } + return domain.ServerFileUploadRequest{ServerInstanceID: r.PathValue("id"), DirectoryKey: r.FormValue("directoryKey"), RelativePath: r.FormValue("relativePath"), Filename: filename, Payload: payload, Checksum: checksum, IdempotencyKey: r.FormValue("idempotencyKey")}, nil +} + // fileOperationDispatch godoc // @Summary Dispatch scoped file operation // @Description Queues a scoped files.read or files.write job using logical file keys or refs, never raw host paths. @@ -1673,6 +1873,25 @@ func (h *coreHandlers) runJobUpdateChunk(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, dto.RunUpdateChunkFromDomain(result)) } +// runFileInputChunk serves one bounded browser-uploaded file input range only to the active fenced Run attempt. +func (h *coreHandlers) runFileInputChunk(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.RunFileInputChunkRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + result, err := h.core.ReadRunFileInputChunk(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.RunFileInputChunkFromDomain(result)) +} + // runJobUpdateHealth godoc // @Summary Confirm a reconciled Run self-update outcome // @Description Accepts a signed current-session health or rollback report fenced to the terminal update job attempt. diff --git a/platform/api/resource_handlers_test.go b/platform/api/resource_handlers_test.go index 9281c47..4826430 100644 --- a/platform/api/resource_handlers_test.go +++ b/platform/api/resource_handlers_test.go @@ -150,7 +150,9 @@ func TestMetricsAndConfigReadAPIAreSafeAndRoleScoped(t *testing.T) { otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "other-metrics@example.test", Password: "secret-password"}).SessionID postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest()) - postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest()) + endpointRequest := validRunEndpointRequest() + endpointRequest.Capabilities = append(endpointRequest.Capabilities, domain.JobCapabilityFilesList) + postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpointRequest) instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ ID: "server-metrics-api", PluginID: "server.scum", @@ -234,11 +236,20 @@ func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) { } } -func TestCoreAPIDeclaredFileReadSnapshotRouteIsScopedAndRedacted(t *testing.T) { +func TestCoreAPIServerFileWorkspaceRoutesAreScoped(t *testing.T) { router := newTestRouter() adminSession := createAdminSession(t, router) - postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest()) - postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest()) + postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ID: "file-workspace-other", DisplayName: "File Workspace Other", Email: "file-workspace-other@example.test", Roles: []string{"server-admin"}, Password: "secret-password"}, adminSession) + otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "file-workspace-other@example.test", Password: "secret-password"}).SessionID + pluginRequest := validGamePluginRequest() + pluginRequest.RequiredRunCapabilities = append(pluginRequest.RequiredRunCapabilities, domain.JobCapabilityFilesList, domain.JobCapabilityFilesRead, domain.JobCapabilityFilesWrite) + pluginRequest.DeclaredPermissions = []string{"server.files.read", "server.files.write"} + pluginRequest.Permissions.Files = true + pluginRequest.FileWorkspace = dto.PluginFileWorkspaceBody{DefaultDirectoryKey: "scum-config", Directories: []dto.PluginLogicalDirectoryBody{{Key: "scum-config", Label: "服务器配置", Scope: "config"}, {Key: "scum-logs", Label: "日志文件", Scope: "logs"}}, Files: []dto.PluginLogicalFileBody{{Key: "scum-server-settings", DirectoryKey: "scum-config", Label: "ServerSettings.ini", Kind: "config", Editable: true}, {Key: "scum-chat-log", DirectoryKey: "scum-logs", Label: "Chat.log", Kind: "log", StreamKey: "scum.chat"}}} + postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest) + endpointRequest := validRunEndpointRequest() + endpointRequest.Capabilities = append(endpointRequest.Capabilities, domain.JobCapabilityFilesList) + postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpointRequest) instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ ID: "server-file-snapshot-api", PluginID: "server.scum", @@ -246,8 +257,33 @@ func TestCoreAPIDeclaredFileReadSnapshotRouteIsScopedAndRedacted(t *testing.T) { Name: "File Snapshot API Server", State: domain.ServerInstanceStateRunning, }, adminSession) + putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, adminSession) - assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", "", adminSession), http.StatusNotFound) + workspace := getJSONWithAuth[dto.ServerFileWorkspaceResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/workspace", adminSession) + if workspace.DefaultDirectoryKey != "scum-config" || workspace.Transfer.Channel != "run-file-transfer" || len(workspace.Files) != 2 { + t.Fatalf("unexpected workspace: %+v", workspace) + } + list := getJSONWithAuth[dto.ServerFileListResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/list?directoryKey=scum-config", adminSession) + foundSettings := false + for _, entry := range list.Entries { + if entry.LogicalKey == "scum-server-settings" && entry.Editable && entry.Downloadable { + foundSettings = true + } + } + if list.State != "declared" || !foundSettings { + t.Fatalf("expected declared file list, got %+v", list) + } + readRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+instance.ID+"/files/read", dto.ServerFileReadRequest{PluginID: "server.scum", Key: "scum-server-settings", IdempotencyKey: "api-file-read"}, adminSession) + assertStatus(t, readRecorder, http.StatusAccepted) + read := decodeBody[dto.FileOperationDispatchResponse](t, readRecorder) + if read.Job.Capability != domain.JobCapabilityFilesRead || read.Job.TargetKey != "scum-server-settings" { + t.Fatalf("unexpected read dispatch: %+v", read) + } + snapshot := getJSONWithAuth[dto.DeclaredFileReadSnapshotResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", adminSession) + if snapshot.State != "pending" || snapshot.JobID != read.Job.ID { + t.Fatalf("expected pending read snapshot, got %+v", snapshot) + } + assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/workspace", "", otherSession), http.StatusForbidden, errorCodeForbidden) } func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) { diff --git a/platform/api/routes.md b/platform/api/routes.md index 9aabdd8..7c62d36 100644 --- a/platform/api/routes.md +++ b/platform/api/routes.md @@ -4,7 +4,7 @@ Route declarations and handler comments live in `platform/api`. Request, respons ## Implemented Core Resource Routes -All routes use JSON request and response bodies. Collection routes support `GET` for lists and `POST` for create. Detail routes support `GET` by ID. Unsupported methods return `dto.ErrorResponse` with `405`. +Routes use JSON request and response bodies unless a route explicitly accepts file upload multipart form data. Collection routes support `GET` for lists and `POST` for create. Detail routes support `GET` by ID. Unsupported methods return `dto.ErrorResponse` with `405`. | Resource | Collection | Detail | DTO contracts | | --- | --- | --- | --- | @@ -17,6 +17,7 @@ All routes use JSON request and response bodies. Collection routes support `GET` | Server runtime distribution | n/a | `GET /api/v1/server-instances/{id}/runtime/actions`, `POST /api/v1/server-instances/{id}/run/generate`, `POST /api/v1/server-instances/{id}/run/download`, `POST /api/v1/server-instances/{id}/run/key/reset`, `POST /api/v1/server-instances/{id}/run/update`, `GET /api/v1/server-instances/{id}/run/update`, `POST /api/v1/server-instances/{id}/client-managers/generate`, `POST /api/v1/server-instances/{id}/client-managers/download`, `POST /api/v1/server-instances/{id}/client-managers/key/reset`, `GET /api/v1/server-instances/{id}/dependencies`, `POST /api/v1/server-instances/{id}/dependencies/check`, `POST /api/v1/server-instances/{id}/dependencies/install` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`/`RunUpdateJobListResponse`, `ClientManagerBuildRequest`, `ClientManagerDistributionResponse`, `ClientManagerDownloadRequest`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyCatalogResponse`, `DependencyJobRequest` | | Metrics | `GET /api/v1/metrics/platform`, `GET /api/v1/metrics/server-instances` | n/a | `PlatformResourceUsageResponse`, `ServerMetricsResponse`, `ServerMetricsListResponse` | | File operations | `POST /api/v1/file-operations/dispatch` | n/a | `FileOperationDispatchRequest`, `FileOperationDispatchResponse` | +| Server file manager | `GET /api/v1/server-instances/{id}/files/workspace`, `GET /api/v1/server-instances/{id}/files/list`, `POST /api/v1/server-instances/{id}/files/refresh`, `POST /api/v1/server-instances/{id}/files/read`, `POST /api/v1/server-instances/{id}/files/write`, `POST /api/v1/server-instances/{id}/files/upload`, `POST /api/v1/server-instances/{id}/files/download` | `GET /api/v1/server-instances/{id}/files/read-snapshot` | `ServerFileWorkspaceResponse`, `ServerFileListResponse`, `DeclaredFileReadSnapshotResponse`, `ServerFileReadRequest`, `ServerFileWriteRequest`, `ServerFileUploadResponse`, `ServerFileDownloadRequest`, `ServerFileDownloadResponse` | | Plugin-owned data | n/a | `GET/PUT/DELETE /api/v1/server-instances/{id}/plugin-data/{collection}`, `POST .../plugin-data/{collection}/transaction` | `PluginDataPutRequest`, `PluginDataTransactionRequest`, `PluginDataRecordResponse`, `PluginDataListResponse` | | Server administrators | `GET /api/v1/server-instances/{id}/administrators/candidates`, `POST /api/v1/server-instances/{id}/administrators` | `DELETE /api/v1/server-instances/{id}/administrators/{userId}` | `ServerMemberRequest`, `ServerMemberResponse`, `ServerMemberListResponse`, `ServerInstanceResponse` | | Run endpoints | `GET /api/v1/run/endpoints`, `POST /api/v1/run/endpoints` | `GET /api/v1/run/endpoints/{id}` | `RunEndpointCreateRequest`, `RunEndpointResponse`, `RunEndpointListResponse` | @@ -186,6 +187,12 @@ Control is the highest-priority run-facing channel; artifact/file transfer press Run job actions carry bounded job metadata only: job ID, run endpoint ID, server instance ID, capability, idempotency key, lease token, attempt/retry limits, deadlines, progress, terminal state, message, error code, result reference, and timing hints. Raw lease tokens exist only on the signed Run job channel; platform persistence stores their hashes. User-facing Job responses expose safe attempt, retry, cancel, terminal, and reconcile projections but never raw/hashed leases, Run sessions, secret refs, host paths, sockets, or credentials. Job ack/progress/result/cancel/reconcile calls remain lightweight and independently valid while log batches or artifact/file chunks are queued, slow, or retrying. Equivalent duplicate terminal results remain idempotent under channel pressure. +## Implemented Run File Transfer Actions + +- `POST /api/v1/run/files/input-chunk`: accept `RunFileInputChunkRequest`, validate the active Run endpoint session plus the fenced file-write job lease/attempt, and return `RunFileInputChunkResponse` with a bounded byte range from a server-instance scoped upload artifact. + +Run file input chunks are used only for browser-staged file uploads that produce `artifact://` job inputs. The route never returns storage backend paths, browser bearer credentials, machine paths, direct sockets, or unrestricted artifact bodies. This channel is lower priority than control, job lifecycle calls, and log ingest. + ## Implemented Log Ingest Actions - `POST /api/v1/run/logs/batches`: accept `LogBatchIngestRequest`, validate run session and stream metadata, store contiguous entries, update `LogStream.LatestSeq`, and return `LogBatchIngestResponse` with the acknowledged range. diff --git a/platform/domain/resources.go b/platform/domain/resources.go index 9dfa3f7..7777fee 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -1000,6 +1000,7 @@ type ServerConfigWriteDispatch struct { type FileOperationKind string const ( + FileOperationList FileOperationKind = "list" FileOperationRead FileOperationKind = "read" FileOperationWrite FileOperationKind = "write" ) @@ -1042,6 +1043,151 @@ type DeclaredFileReadSnapshot struct { Reason string } +type ServerFileEntryKind string + +const ( + ServerFileEntryDirectory ServerFileEntryKind = "directory" + ServerFileEntryFile ServerFileEntryKind = "file" +) + +type ServerFileTransferPolicy struct { + Channel string + UploadChunkSizeBytes int + DownloadChunkSizeBytes int + MaxInlineEditBytes int + MaxBrowserUploadBytes int64 + Notes []string +} + +type ServerFileWorkspaceView struct { + ServerInstanceID string + PluginID string + DefaultDirectoryKey string + Directories []PluginLogicalDirectory + Files []PluginLogicalFile + ConfigFields []PluginConfigField + Transfer ServerFileTransferPolicy + DeclaredOnly bool + RuntimeWorkspaceScope string +} + +type ServerFileEntry struct { + Name string + Kind ServerFileEntryKind + DirectoryKey string + RelativePath string + LogicalKey string + Scope string + SizeBytes int64 + ModifiedAt time.Time + Checksum string + Editable bool + Downloadable bool + Remark string +} + +type ServerFileListRequest struct { + ServerInstanceID string + DirectoryKey string + Path string + Query string + Recursive bool + IdempotencyKey string +} + +type ServerFileListResult struct { + ServerInstanceID string + PluginID string + DirectoryKey string + Path string + State string + Entries []ServerFileEntry + Job Job + RefreshedAt time.Time + Reason string +} + +type ServerFileReadRequest struct { + ServerInstanceID string + PluginID string + Key string + IdempotencyKey string +} + +type ServerFileWriteRequest struct { + ServerInstanceID string + PluginID string + Key string + Content string + InputRef string + ExpectedVersion int + ExpectedChecksum string + IdempotencyKey string +} + +type ServerFileUploadRequest struct { + ServerInstanceID string + DirectoryKey string + RelativePath string + Filename string + Payload []byte + Checksum string + IdempotencyKey string +} + +type ServerFileUploadDispatch struct { + Status string + ServerInstanceID string + DirectoryKey string + RelativePath string + ArtifactID string + InputRef string + SizeBytes int64 + Checksum string + Job Job +} + +type ServerFileDownloadRequest struct { + ServerInstanceID string + Key string + IdempotencyKey string +} + +type ServerFileDownloadResult struct { + Status string + ServerInstanceID string + Key string + Filename string + ContentType string + Content string + Checksum string + SizeBytes int64 + Artifact *ArtifactDownloadReference + Job Job + ReadAt time.Time + Reason string +} + +type RunFileInputChunkRequest struct { + RunEndpointID string + SessionToken string + JobID string + LeaseToken string + Attempt int + Offset int64 + Length int +} + +type RunFileInputChunk struct { + JobID string + ArtifactID string + Offset int64 + TotalBytes int64 + Checksum string + Payload []byte + Complete bool +} + type RunCapacity struct { MaxJobs int RunningJobs int @@ -1054,6 +1200,7 @@ type RunCapacity struct { const ( JobCapabilityConfigWrite = "config.write" + JobCapabilityFilesList = "files.list" JobCapabilityFilesRead = "files.read" JobCapabilityFilesWrite = "files.write" JobCapabilityRemoteFTPRead = "remote.ftp.read" @@ -1963,6 +2110,66 @@ func CopyFileOperationDispatchResult(result FileOperationDispatchResult) FileOpe return result } +func CopyServerFileTransferPolicy(policy ServerFileTransferPolicy) ServerFileTransferPolicy { + policy.Notes = CopyStringSlice(policy.Notes) + return policy +} + +func CopyServerFileWorkspaceView(view ServerFileWorkspaceView) ServerFileWorkspaceView { + view.Directories = append([]PluginLogicalDirectory(nil), view.Directories...) + view.Files = append([]PluginLogicalFile(nil), view.Files...) + view.ConfigFields = append([]PluginConfigField(nil), view.ConfigFields...) + view.Transfer = CopyServerFileTransferPolicy(view.Transfer) + return view +} + +func CopyServerFileEntry(entry ServerFileEntry) ServerFileEntry { + return entry +} + +func CopyServerFileEntries(entries []ServerFileEntry) []ServerFileEntry { + if entries == nil { + return nil + } + out := make([]ServerFileEntry, len(entries)) + copy(out, entries) + return out +} + +func CopyServerFileListResult(result ServerFileListResult) ServerFileListResult { + result.Entries = CopyServerFileEntries(result.Entries) + result.Job = CopyJob(result.Job) + return result +} + +func CopyServerFileUploadRequest(request ServerFileUploadRequest) ServerFileUploadRequest { + request.Payload = CopyBytes(request.Payload) + return request +} + +func CopyServerFileUploadDispatch(dispatch ServerFileUploadDispatch) ServerFileUploadDispatch { + dispatch.Job = CopyJob(dispatch.Job) + return dispatch +} + +func CopyServerFileDownloadResult(result ServerFileDownloadResult) ServerFileDownloadResult { + if result.Artifact != nil { + artifact := CopyArtifactDownloadReference(*result.Artifact) + result.Artifact = &artifact + } + result.Job = CopyJob(result.Job) + return result +} + +func CopyRunFileInputChunkRequest(request RunFileInputChunkRequest) RunFileInputChunkRequest { + return request +} + +func CopyRunFileInputChunk(chunk RunFileInputChunk) RunFileInputChunk { + chunk.Payload = CopyBytes(chunk.Payload) + return chunk +} + func CopyRunEndpoint(endpoint RunEndpoint) RunEndpoint { endpoint.Capabilities = CopyStringSlice(endpoint.Capabilities) endpoint.Capacity.PressureCodes = CopyStringSlice(endpoint.Capacity.PressureCodes) diff --git a/platform/dto/job_channel.go b/platform/dto/job_channel.go index db7b7c1..b744703 100644 --- a/platform/dto/job_channel.go +++ b/platform/dto/job_channel.go @@ -310,6 +310,26 @@ type RunUpdateChunkResponse struct { Complete bool `json:"complete"` } +type RunFileInputChunkRequest struct { + RunEndpointID string `json:"runEndpointId"` + SessionToken string `json:"sessionToken"` + JobID string `json:"jobId"` + LeaseToken string `json:"leaseToken"` + Attempt int `json:"attempt"` + Offset int64 `json:"offset"` + Length int `json:"length"` +} + +type RunFileInputChunkResponse struct { + JobID string `json:"jobId"` + ArtifactID string `json:"artifactId"` + Offset int64 `json:"offset"` + TotalBytes int64 `json:"totalBytes"` + Checksum string `json:"checksum"` + Payload []byte `json:"payload"` + Complete bool `json:"complete"` +} + type RunUpdateHealthRequest struct { RunEndpointID string `json:"runEndpointId"` SessionToken string `json:"sessionToken"` @@ -428,6 +448,10 @@ func (request RunJobResultRequest) ToDomain() domain.RunJobResult { } } +func (request RunFileInputChunkRequest) ToDomain() domain.RunFileInputChunkRequest { + return domain.RunFileInputChunkRequest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt, Offset: request.Offset, Length: request.Length} +} + func (request DistributionBuildInputRequest) ToDomain() domain.DistributionBuildInputRequest { return domain.DistributionBuildInputRequest{ RunEndpointID: request.RunEndpointID, @@ -569,6 +593,11 @@ func RunUpdateChunkFromDomain(chunk domain.RunUpdateChunk) RunUpdateChunkRespons return RunUpdateChunkResponse{JobID: chunk.JobID, ArtifactID: chunk.ArtifactID, Offset: chunk.Offset, TotalBytes: chunk.TotalBytes, Checksum: chunk.Checksum, Payload: chunk.Payload, Complete: chunk.Complete} } +func RunFileInputChunkFromDomain(chunk domain.RunFileInputChunk) RunFileInputChunkResponse { + chunk = domain.CopyRunFileInputChunk(chunk) + return RunFileInputChunkResponse{JobID: chunk.JobID, ArtifactID: chunk.ArtifactID, Offset: chunk.Offset, TotalBytes: chunk.TotalBytes, Checksum: chunk.Checksum, Payload: chunk.Payload, Complete: chunk.Complete} +} + func RunUpdateHealthFromDomain(result domain.RunUpdateHealthResult) RunUpdateHealthResponse { return RunUpdateHealthResponse{Accepted: result.Accepted, JobID: result.JobID, Phase: result.Phase, ServerTime: result.ServerTime} } diff --git a/platform/dto/resources.go b/platform/dto/resources.go index e788016..f4d4f77 100644 --- a/platform/dto/resources.go +++ b/platform/dto/resources.go @@ -730,6 +730,110 @@ type DeclaredFileReadSnapshotResponse struct { Reason string `json:"reason,omitempty"` } +type ServerFileTransferPolicyResponse struct { + Channel string `json:"channel"` + UploadChunkSizeBytes int `json:"uploadChunkSizeBytes"` + DownloadChunkSizeBytes int `json:"downloadChunkSizeBytes"` + MaxInlineEditBytes int `json:"maxInlineEditBytes"` + MaxBrowserUploadBytes int64 `json:"maxBrowserUploadBytes"` + Notes []string `json:"notes,omitempty"` +} + +type ServerFileWorkspaceResponse struct { + ServerInstanceID string `json:"serverInstanceId"` + PluginID string `json:"pluginId"` + DefaultDirectoryKey string `json:"defaultDirectoryKey"` + Directories []PluginLogicalDirectoryBody `json:"directories"` + Files []PluginLogicalFileBody `json:"files"` + ConfigFields []PluginConfigFieldBody `json:"configFields"` + Transfer ServerFileTransferPolicyResponse `json:"transfer"` + DeclaredOnly bool `json:"declaredOnly"` + RuntimeWorkspaceScope string `json:"runtimeWorkspaceScope,omitempty"` +} + +type ServerFileEntryResponse struct { + Name string `json:"name"` + Kind domain.ServerFileEntryKind `json:"kind"` + DirectoryKey string `json:"directoryKey"` + RelativePath string `json:"relativePath,omitempty"` + LogicalKey string `json:"logicalKey,omitempty"` + Scope string `json:"scope,omitempty"` + SizeBytes int64 `json:"sizeBytes,omitempty"` + ModifiedAt time.Time `json:"modifiedAt,omitempty"` + Checksum string `json:"checksum,omitempty"` + Editable bool `json:"editable"` + Downloadable bool `json:"downloadable"` + Remark string `json:"remark,omitempty"` +} + +type ServerFileListRequest struct { + DirectoryKey string `json:"directoryKey"` + Path string `json:"path,omitempty"` + Query string `json:"query,omitempty"` + Recursive bool `json:"recursive,omitempty"` + IdempotencyKey string `json:"idempotencyKey,omitempty"` +} + +type ServerFileListResponse struct { + ServerInstanceID string `json:"serverInstanceId"` + PluginID string `json:"pluginId"` + DirectoryKey string `json:"directoryKey"` + Path string `json:"path,omitempty"` + State string `json:"state"` + Entries []ServerFileEntryResponse `json:"entries"` + Job *JobResponse `json:"job,omitempty"` + RefreshedAt time.Time `json:"refreshedAt,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type ServerFileReadRequest struct { + PluginID string `json:"pluginId,omitempty"` + Key string `json:"key"` + IdempotencyKey string `json:"idempotencyKey"` +} + +type ServerFileWriteRequest struct { + PluginID string `json:"pluginId,omitempty"` + Key string `json:"key"` + Content string `json:"content,omitempty"` + InputRef string `json:"inputRef,omitempty"` + ExpectedVersion int `json:"expectedVersion,omitempty"` + ExpectedChecksum string `json:"expectedChecksum,omitempty"` + IdempotencyKey string `json:"idempotencyKey"` +} + +type ServerFileUploadResponse struct { + Status string `json:"status"` + ServerInstanceID string `json:"serverInstanceId"` + DirectoryKey string `json:"directoryKey"` + RelativePath string `json:"relativePath"` + ArtifactID string `json:"artifactId"` + InputRef string `json:"inputRef"` + SizeBytes int64 `json:"sizeBytes"` + Checksum string `json:"checksum"` + Job JobResponse `json:"job"` +} + +type ServerFileDownloadRequest struct { + Key string `json:"key"` + IdempotencyKey string `json:"idempotencyKey"` +} + +type ServerFileDownloadResponse struct { + Status string `json:"status"` + ServerInstanceID string `json:"serverInstanceId"` + Key string `json:"key"` + Filename string `json:"filename"` + ContentType string `json:"contentType,omitempty"` + Content string `json:"content,omitempty"` + Checksum string `json:"checksum,omitempty"` + SizeBytes int64 `json:"sizeBytes,omitempty"` + Artifact *ArtifactDownloadReferenceResponse `json:"artifact,omitempty"` + Job *JobResponse `json:"job,omitempty"` + ReadAt time.Time `json:"readAt,omitempty"` + Reason string `json:"reason,omitempty"` +} + type RunCapacityResponse struct { MaxJobs int `json:"maxJobs"` RunningJobs int `json:"runningJobs"` @@ -1295,6 +1399,22 @@ func (request FileOperationDispatchRequest) ToDomain() domain.FileOperationDispa } } +func (request ServerFileListRequest) ToDomain(serverInstanceID string) domain.ServerFileListRequest { + return domain.ServerFileListRequest{ServerInstanceID: serverInstanceID, DirectoryKey: request.DirectoryKey, Path: request.Path, Query: request.Query, Recursive: request.Recursive, IdempotencyKey: request.IdempotencyKey} +} + +func (request ServerFileReadRequest) ToDomain(serverInstanceID string) domain.ServerFileReadRequest { + return domain.ServerFileReadRequest{ServerInstanceID: serverInstanceID, PluginID: request.PluginID, Key: request.Key, IdempotencyKey: request.IdempotencyKey} +} + +func (request ServerFileWriteRequest) ToDomain(serverInstanceID string) domain.ServerFileWriteRequest { + return domain.ServerFileWriteRequest{ServerInstanceID: serverInstanceID, PluginID: request.PluginID, Key: request.Key, Content: request.Content, InputRef: request.InputRef, ExpectedVersion: request.ExpectedVersion, ExpectedChecksum: request.ExpectedChecksum, IdempotencyKey: request.IdempotencyKey} +} + +func (request ServerFileDownloadRequest) ToDomain(serverInstanceID string) domain.ServerFileDownloadRequest { + return domain.ServerFileDownloadRequest{ServerInstanceID: serverInstanceID, Key: request.Key, IdempotencyKey: request.IdempotencyKey} +} + func (request RunEndpointCreateRequest) ToDomain() domain.RunEndpoint { return domain.RunEndpoint{ ID: request.ID, @@ -1827,6 +1947,57 @@ func DeclaredFileReadSnapshotFromDomain(snapshot domain.DeclaredFileReadSnapshot } } +func ServerFileWorkspaceFromDomain(view domain.ServerFileWorkspaceView) ServerFileWorkspaceResponse { + view = domain.CopyServerFileWorkspaceView(view) + workspace := domain.PluginFileWorkspace{DefaultDirectoryKey: view.DefaultDirectoryKey, Directories: view.Directories, Files: view.Files, ConfigFields: view.ConfigFields} + body := fileWorkspaceFromDomain(workspace) + return ServerFileWorkspaceResponse{ServerInstanceID: view.ServerInstanceID, PluginID: view.PluginID, DefaultDirectoryKey: view.DefaultDirectoryKey, Directories: body.Directories, Files: body.Files, ConfigFields: body.ConfigFields, Transfer: ServerFileTransferPolicyFromDomain(view.Transfer), DeclaredOnly: view.DeclaredOnly, RuntimeWorkspaceScope: view.RuntimeWorkspaceScope} +} + +func ServerFileTransferPolicyFromDomain(policy domain.ServerFileTransferPolicy) ServerFileTransferPolicyResponse { + policy = domain.CopyServerFileTransferPolicy(policy) + return ServerFileTransferPolicyResponse{Channel: policy.Channel, UploadChunkSizeBytes: policy.UploadChunkSizeBytes, DownloadChunkSizeBytes: policy.DownloadChunkSizeBytes, MaxInlineEditBytes: policy.MaxInlineEditBytes, MaxBrowserUploadBytes: policy.MaxBrowserUploadBytes, Notes: policy.Notes} +} + +func ServerFileListFromDomain(result domain.ServerFileListResult) ServerFileListResponse { + result = domain.CopyServerFileListResult(result) + items := make([]ServerFileEntryResponse, len(result.Entries)) + for index, entry := range result.Entries { + items[index] = ServerFileEntryFromDomain(entry) + } + var job *JobResponse + if result.Job.ID != "" { + body := JobFromDomain(result.Job) + job = &body + } + return ServerFileListResponse{ServerInstanceID: result.ServerInstanceID, PluginID: result.PluginID, DirectoryKey: result.DirectoryKey, Path: result.Path, State: result.State, Entries: items, Job: job, RefreshedAt: result.RefreshedAt, Reason: result.Reason} +} + +func ServerFileEntryFromDomain(entry domain.ServerFileEntry) ServerFileEntryResponse { + entry = domain.CopyServerFileEntry(entry) + return ServerFileEntryResponse{Name: entry.Name, Kind: entry.Kind, DirectoryKey: entry.DirectoryKey, RelativePath: entry.RelativePath, LogicalKey: entry.LogicalKey, Scope: entry.Scope, SizeBytes: entry.SizeBytes, ModifiedAt: entry.ModifiedAt, Checksum: entry.Checksum, Editable: entry.Editable, Downloadable: entry.Downloadable, Remark: entry.Remark} +} + +func ServerFileUploadFromDomain(dispatch domain.ServerFileUploadDispatch) ServerFileUploadResponse { + dispatch = domain.CopyServerFileUploadDispatch(dispatch) + return ServerFileUploadResponse{Status: dispatch.Status, ServerInstanceID: dispatch.ServerInstanceID, DirectoryKey: dispatch.DirectoryKey, RelativePath: dispatch.RelativePath, ArtifactID: dispatch.ArtifactID, InputRef: dispatch.InputRef, SizeBytes: dispatch.SizeBytes, Checksum: dispatch.Checksum, Job: JobFromDomain(dispatch.Job)} +} + +func ServerFileDownloadFromDomain(result domain.ServerFileDownloadResult) ServerFileDownloadResponse { + result = domain.CopyServerFileDownloadResult(result) + var artifact *ArtifactDownloadReferenceResponse + if result.Artifact != nil { + body := ArtifactDownloadReferenceFromDomain(*result.Artifact) + artifact = &body + } + var job *JobResponse + if result.Job.ID != "" { + body := JobFromDomain(result.Job) + job = &body + } + return ServerFileDownloadResponse{Status: result.Status, ServerInstanceID: result.ServerInstanceID, Key: result.Key, Filename: result.Filename, ContentType: result.ContentType, Content: result.Content, Checksum: result.Checksum, SizeBytes: result.SizeBytes, Artifact: artifact, Job: job, ReadAt: result.ReadAt, Reason: result.Reason} +} + func RunEndpointFromDomain(endpoint domain.RunEndpoint) RunEndpointResponse { endpoint = domain.CopyRunEndpoint(endpoint) return RunEndpointResponse{ diff --git a/platform/protocol/run-contracts.md b/platform/protocol/run-contracts.md index cbfe306..1462a06 100644 --- a/platform/protocol/run-contracts.md +++ b/platform/protocol/run-contracts.md @@ -50,7 +50,7 @@ Jobs must carry bounded metadata such as `jobId`, `runEndpointId`, `serverInstan Plugin lifecycle assignments may add only a validated plugin identifier, enumerated lifecycle operation, target version, and logical workspace scope. Explicit operator-requested install, enable, disable, upgrade, rollback, retire, dependency-check, and bounded lifecycle commands remain Platform-authorized jobs. Generated Run package startup is not dependent on registration-time job assignment; it is driven by the autonomous lifecycle plan embedded by the platform builder. Assignments cannot carry arbitrary shell, provider configuration, raw credentials, host paths, PIDs, sockets, DSNs, or RCON secrets. -Approved `config.write` and bounded `files.read`/`files.write` assignments carry logical keys, scoped refs, and compare-and-swap revision/checksum inputs. Run executes them inside its scoped workspace with atomic writes and returns bounded logical result metadata; resolved machine paths remain Run-local. +Approved `config.write` and bounded `files.list`/`files.read`/`files.write` assignments carry logical directory keys, file keys, scoped refs, and compare-and-swap revision/checksum inputs. Run executes them inside its scoped workspace with atomic writes and returns bounded logical result metadata; resolved machine paths remain Run-local. Job ack, progress, cancellation polling, reconciliation, and terminal result calls are lightweight lifecycle metadata. They must remain valid while artifact chunks or log retries are pending, and duplicate equivalent terminal results remain idempotent under channel pressure. @@ -113,6 +113,20 @@ Artifact upload supports active run session validation, job/server-instance owne Artifact/file transfer is the lower-priority heavy channel. Chunk upload and completion must not block control heartbeat, job ack/result delivery, cancellation/reconcile calls, or log ingest acknowledgement. Lightweight routes must reject heavy transfer payloads instead of accepting or storing them. +## Server File Manager Transfer + +Implemented HTTP JSON routes: + +- `POST /api/v1/run/files/input-chunk` + +Browser-facing file management uses server-instance scoped routes on Platform for workspace, list, read, write, upload, and download preparation. The browser only sends plugin-declared logical directory keys, logical file keys, relative names, inline text for small edits, or platform-owned `artifact://` input refs. It never receives or sends host paths, direct Run sockets, Run sessions, job lease tokens, storage credentials, or raw machine endpoints. + +`files.list` jobs return a bounded `file.list` execution result containing logical entries. `files.read` jobs may return a bounded inline `file.read` result for editable text or a `resultRef` pointing to a platform artifact for larger content. Browser downloads are prepared through Platform and then read in bounded chunks using the platform artifact download contract. + +Browser uploads are first staged as server-instance artifacts. Platform then queues a `files.write` job whose `inputRef` is `artifact://` and whose execution input names the dedicated `run-file-transfer` channel. Run pulls those bytes through `POST /api/v1/run/files/input-chunk` while proving the active endpoint session plus job attempt and lease. The chunk route is fenced to the active file-write job, validates artifact ownership/checksum, and returns bounded byte ranges only. + +File-manager transfer is a separate, low-priority heavy path. Slow uploads, downloads, retries, or file input chunk pulls must not block control heartbeat, job claim/ack/progress/result/cancel/reconcile, durable log batch ingest, or artifact upload acknowledgements. Control, jobs, logs, artifacts, file transfer, and optional game-client bridge remain independently backpressured channels. + ## Client Manager lifecycle channel Client Manager lifecycle jobs use the independent capabilities `client-manager.deploy`, `client-manager.control`, `client-manager.update`, `client-manager.rollback`, and `client-manager.uninstall`. Run obtains a fenced logical contract from `POST /api/v1/run/jobs/client-manager-input` and reads resumable artifact chunks from `POST /api/v1/run/jobs/client-manager-chunk`; these routes are separate from artifact upload, Run control, logs, and optional game-client traffic. The contract carries installation/profile, target, version/revision, checksum, deployment/key generations, fixed executable reference, bounded arguments/timeouts, and idempotency. For a plugin-declared companion profile it also carries a generic `companionConfig` materialization contract: safe relative template/schema/output references, the fenced component identity, declared component capabilities, Platform URL source, proof environment-variable name, component-session/TLS policy, and bounded timing values. diff --git a/platform/service/job_channel.go b/platform/service/job_channel.go index 9dc75e2..1df8f84 100644 --- a/platform/service/job_channel.go +++ b/platform/service/job_channel.go @@ -311,6 +311,10 @@ func validateExecutionResultForJob(job domain.Job, result domain.RunJobResult) e if result.ExecutionResult.Checksum == "" || result.ExecutionResult.Checksum != validator.BytesChecksum([]byte(job.ExecutionInput.Content)) { return validationError("config write result checksum is invalid") } + case domain.JobCapabilityFilesList: + if result.ExecutionResult.Kind != "file.list" { + return validationError("file list result type is invalid") + } case domain.JobCapabilityFilesRead: if result.ExecutionResult.Kind != "file.read" { return validationError("file read result type is invalid") diff --git a/platform/service/resources.go b/platform/service/resources.go index 673c442..2a2c2ac 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -123,6 +123,13 @@ type Core interface { RequestRemoteAdapterForSession(string, domain.RemoteAdapterRequest) (domain.RemoteAdapterResult, error) GetServerConfigForSession(string, string) (domain.ServerConfig, error) GetDeclaredFileReadSnapshotForSession(string, string, string) (domain.DeclaredFileReadSnapshot, error) + GetServerFileWorkspaceForSession(string, string) (domain.ServerFileWorkspaceView, error) + ListServerFilesForSession(string, domain.ServerFileListRequest) (domain.ServerFileListResult, error) + RefreshServerFileListForSession(string, domain.ServerFileListRequest) (domain.ServerFileListResult, error) + ReadServerFileForSession(string, domain.ServerFileReadRequest) (domain.FileOperationDispatchResult, error) + WriteServerFileForSession(string, domain.ServerFileWriteRequest) (domain.FileOperationDispatchResult, error) + UploadServerFileForSession(string, domain.ServerFileUploadRequest) (domain.ServerFileUploadDispatch, error) + PrepareServerFileDownloadForSession(string, domain.ServerFileDownloadRequest) (domain.ServerFileDownloadResult, error) PreviewServerConfigWriteForSession(string, domain.ServerConfigDiffRequest) (domain.ServerConfigDiffPreview, error) ApproveServerConfigWriteForSession(string, domain.ServerConfigWriteApproval) (domain.ServerConfigWriteDispatch, error) DispatchFileOperationForSession(string, domain.FileOperationDispatchRequest) (domain.FileOperationDispatchResult, error) @@ -143,6 +150,7 @@ type Core interface { GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest) (domain.SourceRCONExecutionInput, error) GetRunUpdateInput(domain.RunUpdateInputRequest) (domain.RunUpdateInput, error) ReadRunUpdateChunk(domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error) + ReadRunFileInputChunk(domain.RunFileInputChunkRequest) (domain.RunFileInputChunk, error) ReportRunUpdateHealth(domain.RunUpdateHealthReport) (domain.RunUpdateHealthResult, error) RequestRunJobCancel(domain.RunJobCancelRequest) (domain.RunJobCancelRequestResult, error) PollRunJobCancel(domain.RunJobCancelPoll) (domain.RunJobCancelPollResult, error) @@ -2156,11 +2164,6 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques if artifact.OwnerKind != domain.ArtifactOwnerKindServerInstance || artifact.OwnerID != instance.ID || artifact.State != domain.ArtifactStateAvailable { return domain.FileOperationDispatchResult{}, ErrForbidden } - payload, payloadErr := svc.artifactPayload(artifactID) - if payloadErr != nil { - return domain.FileOperationDispatchResult{}, payloadErr - } - content = string(payload) } if request.PluginID != "" { plugin, err := svc.store.GamePlugins().Get(request.PluginID) @@ -2173,7 +2176,7 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques if plugin.Status != domain.GamePluginStatusInstalled { return domain.FileOperationDispatchResult{}, validationError("plugin must be installed") } - if request.Operation == domain.FileOperationRead && !plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read") { + if (request.Operation == domain.FileOperationList || request.Operation == domain.FileOperationRead) && !plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read") { return domain.FileOperationDispatchResult{}, ErrForbidden } if request.Operation == domain.FileOperationWrite && !containsString(plugin.DeclaredPermissions, "server.files.write") { @@ -2188,6 +2191,10 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques } capability := domain.JobCapabilityFilesRead message := "file read queued" + if request.Operation == domain.FileOperationList { + capability = domain.JobCapabilityFilesList + message = "file list queued" + } if request.Operation == domain.FileOperationWrite { capability = domain.JobCapabilityFilesWrite message = "file write queued" @@ -2218,6 +2225,17 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques } func declaredPluginFileRequest(workspace domain.PluginFileWorkspace, request domain.FileOperationDispatchRequest) (domain.PluginLogicalFile, bool, bool) { + if request.Operation == domain.FileOperationList { + if len(workspace.Directories) == 0 { + return domain.PluginLogicalFile{}, false, true + } + for _, directory := range workspace.Directories { + if directory.Key == request.Key { + return domain.PluginLogicalFile{}, true, true + } + } + return domain.PluginLogicalFile{}, true, false + } if len(workspace.Files) == 0 { return domain.PluginLogicalFile{}, false, true } diff --git a/platform/service/server_files.go b/platform/service/server_files.go new file mode 100644 index 0000000..82731d9 --- /dev/null +++ b/platform/service/server_files.go @@ -0,0 +1,508 @@ +package service + +import ( + "encoding/json" + "errors" + "fmt" + "path" + "sort" + "strconv" + "strings" + "time" + + "browser.local/platform/domain" + "browser.local/platform/repo" + "browser.local/platform/validator" +) + +const ( + serverFileTransferChannel = "run-file-transfer" + serverFileMaxInlineEditBytes = 64 * 1024 +) + +type serverFileContext struct { + User domain.User + Instance domain.ServerInstance + Plugin domain.GamePlugin + Directory domain.PluginLogicalDirectory + Scope string +} + +type runFileListEnvelope struct { + DirectoryKey string `json:"directoryKey"` + Path string `json:"path"` + Entries []runFileListEntry `json:"entries"` +} + +type runFileListEntry struct { + Name string `json:"name"` + Kind string `json:"kind"` + DirectoryKey string `json:"directoryKey"` + RelativePath string `json:"relativePath"` + LogicalKey string `json:"logicalKey"` + Scope string `json:"scope"` + SizeBytes int64 `json:"sizeBytes"` + ModifiedAt string `json:"modifiedAt"` + Checksum string `json:"checksum"` + Editable bool `json:"editable"` + Downloadable bool `json:"downloadable"` + Remark string `json:"remark"` +} + +func (svc *CoreService) GetServerFileWorkspaceForSession(sessionID string, serverInstanceID string) (domain.ServerFileWorkspaceView, error) { + ctx, err := svc.serverFileContextForSession(sessionID, serverInstanceID, "", false, false) + if err != nil { + return domain.ServerFileWorkspaceView{}, err + } + workspace := domain.CopyPluginFileWorkspace(ctx.Plugin.FileWorkspace) + if workspace.DefaultDirectoryKey == "" && len(workspace.Directories) > 0 { + workspace.DefaultDirectoryKey = workspace.Directories[0].Key + } + view := domain.ServerFileWorkspaceView{ + ServerInstanceID: ctx.Instance.ID, + PluginID: ctx.Plugin.ID, + DefaultDirectoryKey: workspace.DefaultDirectoryKey, + Directories: workspace.Directories, + Files: workspace.Files, + ConfigFields: workspace.ConfigFields, + DeclaredOnly: true, + RuntimeWorkspaceScope: svc.runtimeProfileScope(ctx.Instance.ID), + Transfer: domain.ServerFileTransferPolicy{ + Channel: serverFileTransferChannel, + UploadChunkSizeBytes: validator.MaxArtifactChunkBytes, + DownloadChunkSizeBytes: validator.MaxArtifactDownloadBytes, + MaxInlineEditBytes: serverFileMaxInlineEditBytes, + MaxBrowserUploadBytes: validator.MaxArtifactBytes, + Notes: []string{ + "文件字节通过独立文件传输端点传递,不占用 control、jobs 或 logs 通道。", + "Web 只发送逻辑目录和相对路径;Run 在本机工作区内解析真实路径。", + }, + }, + } + return domain.CopyServerFileWorkspaceView(view), nil +} + +func (svc *CoreService) ListServerFilesForSession(sessionID string, request domain.ServerFileListRequest) (domain.ServerFileListResult, error) { + request = normalizeServerFileListRequest(request) + if err := validator.ValidateServerFileListRequest(request); err != nil { + return domain.ServerFileListResult{}, err + } + ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, false, false) + if err != nil { + return domain.ServerFileListResult{}, err + } + latest, hasLatest, err := svc.latestFileListJob(ctx.Instance.ID, request.DirectoryKey, request.Path) + if err != nil { + return domain.ServerFileListResult{}, err + } + if hasLatest && latest.State == domain.JobStateSucceeded && latest.ExecutionResult.Kind == "file.list" { + entries, parseErr := serverFileEntriesFromRunList(latest.ExecutionResult.Content, request.DirectoryKey, request.Path) + if parseErr == nil { + return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: "ready", Entries: filterServerFileEntries(entries, request.Query), Job: latest, RefreshedAt: latest.TerminalAt}), nil + } + } + state := "declared" + reason := "展示插件声明的逻辑文件;点击刷新可请求 Run 返回实时目录。" + if hasLatest && !isTerminalJobState(latest.State) { + state = "pending" + reason = "Run 正在刷新目录。" + } + entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Plugin.FileWorkspace, request.DirectoryKey), request.Query) + return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: state, Entries: entries, Job: latest, RefreshedAt: latest.TerminalAt, Reason: reason}), nil +} + +func (svc *CoreService) RefreshServerFileListForSession(sessionID string, request domain.ServerFileListRequest) (domain.ServerFileListResult, error) { + request = normalizeServerFileListRequest(request) + if request.IdempotencyKey == "" { + request.IdempotencyKey = fmt.Sprintf("file-list:%s:%s:%s", request.ServerInstanceID, request.DirectoryKey, request.Path) + } + if err := validator.ValidateServerFileListRequest(request); err != nil { + return domain.ServerFileListResult{}, err + } + ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, false, true) + if err != nil { + return domain.ServerFileListResult{}, err + } + job, err := svc.CreateJob(domain.Job{ + ID: jobIDFromParts("job-file-list", request.ServerInstanceID, request.IdempotencyKey), + ServerInstanceID: ctx.Instance.ID, + RunEndpointID: ctx.Instance.RunEndpointID, + Capability: domain.JobCapabilityFilesList, + TargetKey: request.DirectoryKey, + InputRef: "", + ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(ctx.Instance.ID), Inputs: map[string]string{ + "directoryKey": request.DirectoryKey, + "path": request.Path, + "recursive": strconv.FormatBool(request.Recursive), + "query": request.Query, + }, MaxReadBytes: serverFileMaxInlineEditBytes}, + IdempotencyKey: request.IdempotencyKey, + Progress: domain.JobProgress{Percent: 0, Phase: "queued", Message: "file list queued"}, + }) + if err != nil { + return domain.ServerFileListResult{}, err + } + entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Plugin.FileWorkspace, request.DirectoryKey), request.Query) + return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: "pending", Entries: entries, Job: job, Reason: "目录刷新任务已派发到 Run。"}), nil +} + +func (svc *CoreService) ReadServerFileForSession(sessionID string, request domain.ServerFileReadRequest) (domain.FileOperationDispatchResult, error) { + if err := validator.ValidateServerFileReadRequest(request); err != nil { + return domain.FileOperationDispatchResult{}, err + } + return svc.DispatchFileOperationForSession(sessionID, domain.FileOperationDispatchRequest{ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: domain.FileOperationRead, Key: request.Key, IdempotencyKey: request.IdempotencyKey}) +} + +func (svc *CoreService) WriteServerFileForSession(sessionID string, request domain.ServerFileWriteRequest) (domain.FileOperationDispatchResult, error) { + if request.InputRef == "" { + request.InputRef = fileManagerInlineInputRef(request.ServerInstanceID, request.Key, request.IdempotencyKey) + } + if err := validator.ValidateServerFileWriteRequest(request); err != nil { + return domain.FileOperationDispatchResult{}, err + } + return svc.DispatchFileOperationForSession(sessionID, domain.FileOperationDispatchRequest{ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: domain.FileOperationWrite, Key: request.Key, InputRef: request.InputRef, Content: request.Content, ExpectedConfigVersion: request.ExpectedVersion, ExpectedChecksum: request.ExpectedChecksum, IdempotencyKey: request.IdempotencyKey}) +} + +func (svc *CoreService) UploadServerFileForSession(sessionID string, request domain.ServerFileUploadRequest) (domain.ServerFileUploadDispatch, error) { + request = domain.CopyServerFileUploadRequest(request) + if err := validator.ValidateServerFileUploadRequest(request); err != nil { + return domain.ServerFileUploadDispatch{}, err + } + ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, true, true) + if err != nil { + return domain.ServerFileUploadDispatch{}, err + } + if strings.EqualFold(ctx.Directory.Scope, "logs") { + return domain.ServerFileUploadDispatch{}, validationError("log directories are read-only") + } + relativePath := cleanServerFileRelativePath(path.Join(request.RelativePath, request.Filename)) + artifactID := serverFileUploadArtifactID(request.ServerInstanceID, request.IdempotencyKey, relativePath) + artifact := domain.Artifact{ID: artifactID, OwnerKind: domain.ArtifactOwnerKindServerInstance, OwnerID: ctx.Instance.ID, SizeBytes: int64(len(request.Payload)), Checksum: request.Checksum, State: domain.ArtifactStateAvailable, CreatedAt: svc.now(), UpdatedAt: svc.now()} + if err := svc.putBrowserFileArtifact(artifact, request.Payload); err != nil { + return domain.ServerFileUploadDispatch{}, err + } + inputRef := "artifact://" + artifact.ID + targetKey := cleanServerFileRelativePath(path.Join(request.DirectoryKey, relativePath)) + job, err := svc.CreateJob(domain.Job{ + ID: jobIDFromParts("job-file-upload", request.ServerInstanceID, request.IdempotencyKey), + ServerInstanceID: ctx.Instance.ID, + RunEndpointID: ctx.Instance.RunEndpointID, + Capability: domain.JobCapabilityFilesWrite, + TargetKey: targetKey, + InputRef: inputRef, + ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(ctx.Instance.ID), ExpectedChecksum: request.Checksum, Inputs: map[string]string{ + "directoryKey": request.DirectoryKey, + "relativePath": relativePath, + "filename": request.Filename, + "transfer": serverFileTransferChannel, + }}, + IdempotencyKey: request.IdempotencyKey, + Progress: domain.JobProgress{Percent: 0, Phase: "queued", Message: "file upload staged; Run will pull input chunks"}, + }) + if err != nil { + return domain.ServerFileUploadDispatch{}, err + } + return domain.CopyServerFileUploadDispatch(domain.ServerFileUploadDispatch{Status: "queued", ServerInstanceID: ctx.Instance.ID, DirectoryKey: request.DirectoryKey, RelativePath: relativePath, ArtifactID: artifact.ID, InputRef: inputRef, SizeBytes: artifact.SizeBytes, Checksum: artifact.Checksum, Job: job}), nil +} + +func (svc *CoreService) PrepareServerFileDownloadForSession(sessionID string, request domain.ServerFileDownloadRequest) (domain.ServerFileDownloadResult, error) { + if err := validator.ValidateServerFileDownloadRequest(request); err != nil { + return domain.ServerFileDownloadResult{}, err + } + ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, "", false, true) + if err != nil { + return domain.ServerFileDownloadResult{}, err + } + filename := serverFileDisplayName(ctx.Plugin.FileWorkspace, request.Key) + job, hasJob, err := svc.latestFileReadJob(ctx.Instance.ID, request.Key) + if err != nil { + return domain.ServerFileDownloadResult{}, err + } + if hasJob && job.State == domain.JobStateSucceeded && job.ExecutionResult.Kind == "file.read" { + if strings.HasPrefix(job.ResultRef, "artifact://") { + reference, openErr := svc.OpenArtifactDownloadForSession(sessionID, domain.ArtifactDownloadReferenceRequest{ArtifactID: strings.TrimPrefix(job.ResultRef, "artifact://")}) + if openErr != nil { + return domain.ServerFileDownloadResult{}, openErr + } + return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "ready", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, ContentType: reference.ContentType, Checksum: reference.Checksum, SizeBytes: reference.SizeBytes, Artifact: &reference, Job: job, ReadAt: job.TerminalAt}), nil + } + if job.ExecutionResult.Content != "" { + content := redactDeclaredFileReadContent(job.ExecutionResult.Content) + return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "ready", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, ContentType: "text/plain; charset=utf-8", Content: content, Checksum: job.ExecutionResult.Checksum, SizeBytes: int64(len([]byte(content))), Job: job, ReadAt: job.TerminalAt}), nil + } + } + if hasJob && !isTerminalJobState(job.State) { + return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "pending", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, Job: job, Reason: "文件读取任务仍在执行。"}), nil + } + dispatch, err := svc.ReadServerFileForSession(sessionID, domain.ServerFileReadRequest{ServerInstanceID: request.ServerInstanceID, PluginID: ctx.Plugin.ID, Key: request.Key, IdempotencyKey: request.IdempotencyKey}) + if err != nil { + return domain.ServerFileDownloadResult{}, err + } + return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "pending", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, Job: dispatch.Job, Reason: "文件读取任务已派发;完成后可再次下载。"}), nil +} + +func (svc *CoreService) ReadRunFileInputChunk(request domain.RunFileInputChunkRequest) (domain.RunFileInputChunk, error) { + request = domain.CopyRunFileInputChunkRequest(request) + if err := validator.ValidateRunFileInputChunkRequest(request); err != nil { + return domain.RunFileInputChunk{}, err + } + session, err := svc.validatedRunSession(request.RunEndpointID, request.SessionToken) + if err != nil { + return domain.RunFileInputChunk{}, err + } + svc.jobMu.Lock() + job, err := svc.fencedJob(session, request.JobID, request.LeaseToken, request.Attempt) + if err != nil { + svc.jobMu.Unlock() + return domain.RunFileInputChunk{}, err + } + if job.Capability != domain.JobCapabilityFilesWrite || !strings.HasPrefix(job.InputRef, "artifact://") { + svc.jobMu.Unlock() + return domain.RunFileInputChunk{}, validationError("job does not reference a file input artifact") + } + if job.State != domain.JobStateAccepted && job.State != domain.JobStateRunning { + svc.jobMu.Unlock() + return domain.RunFileInputChunk{}, validationError("job is not active") + } + artifactID := strings.TrimPrefix(job.InputRef, "artifact://") + serverInstanceID := job.ServerInstanceID + svc.jobMu.Unlock() + + artifact, err := svc.store.Artifacts().Get(artifactID) + if err != nil { + return domain.RunFileInputChunk{}, err + } + if artifact.OwnerKind != domain.ArtifactOwnerKindServerInstance || artifact.OwnerID != serverInstanceID || artifact.State != domain.ArtifactStateAvailable { + return domain.RunFileInputChunk{}, ErrForbidden + } + payload, err := svc.artifactPayload(artifact.ID) + if err != nil { + return domain.RunFileInputChunk{}, err + } + if int64(len(payload)) != artifact.SizeBytes || validator.BytesChecksum(payload) != artifact.Checksum { + return domain.RunFileInputChunk{}, validationError("file input artifact checksum mismatch") + } + if request.Offset >= artifact.SizeBytes { + return domain.RunFileInputChunk{}, validationError("offset must be inside artifact content") + } + length := request.Length + remaining := artifact.SizeBytes - request.Offset + if int64(length) > remaining { + length = int(remaining) + } + end := int(request.Offset) + length + chunk := domain.RunFileInputChunk{JobID: job.ID, ArtifactID: artifact.ID, Offset: request.Offset, TotalBytes: artifact.SizeBytes, Checksum: artifact.Checksum, Payload: payload[int(request.Offset):end], Complete: int64(end) == artifact.SizeBytes} + return domain.CopyRunFileInputChunk(chunk), nil +} + +func (svc *CoreService) serverFileContextForSession(sessionID string, serverInstanceID string, directoryKey string, requireWrite bool, requireRuntime bool) (serverFileContext, error) { + instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID) + if err != nil { + return serverFileContext{}, err + } + user, err := svc.GetCurrentUser(sessionID) + if err != nil { + return serverFileContext{}, err + } + if requireRuntime { + if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "file.manager.denied"); err != nil { + return serverFileContext{}, err + } + } + plugin, err := svc.store.GamePlugins().Get(instance.PluginID) + if err != nil { + return serverFileContext{}, err + } + if plugin.Status != domain.GamePluginStatusInstalled { + return serverFileContext{}, validationError("plugin must be installed") + } + if requireWrite { + if !containsString(plugin.DeclaredPermissions, "server.files.write") { + return serverFileContext{}, ErrForbidden + } + } else if !plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read") { + return serverFileContext{}, ErrForbidden + } + directory := domain.PluginLogicalDirectory{} + if directoryKey != "" { + var found bool + for _, candidate := range plugin.FileWorkspace.Directories { + if candidate.Key == directoryKey { + directory = candidate + found = true + break + } + } + if !found && len(plugin.FileWorkspace.Directories) > 0 { + return serverFileContext{}, validationError("directoryKey must reference a plugin-declared directory") + } + } + return serverFileContext{User: user, Instance: instance, Plugin: plugin, Directory: directory, Scope: svc.runtimeProfileScope(instance.ID)}, nil +} + +func normalizeServerFileListRequest(request domain.ServerFileListRequest) domain.ServerFileListRequest { + request.DirectoryKey = strings.TrimSpace(request.DirectoryKey) + request.Path = cleanServerFileRelativePath(request.Path) + request.Query = strings.TrimSpace(request.Query) + return request +} + +func serverFileEntriesFromDeclaredWorkspace(workspace domain.PluginFileWorkspace, directoryKey string) []domain.ServerFileEntry { + entries := make([]domain.ServerFileEntry, 0) + for _, directory := range workspace.Directories { + if directoryKey == "" || directory.Key == directoryKey { + continue + } + entries = append(entries, domain.ServerFileEntry{Name: directory.Label, Kind: domain.ServerFileEntryDirectory, DirectoryKey: directory.Key, RelativePath: "", Scope: directory.Scope, Editable: false, Downloadable: false, Remark: "插件声明目录"}) + } + for _, file := range workspace.Files { + if directoryKey != "" && file.DirectoryKey != directoryKey { + continue + } + entries = append(entries, domain.ServerFileEntry{Name: file.Label, Kind: domain.ServerFileEntryFile, DirectoryKey: file.DirectoryKey, RelativePath: file.Key, LogicalKey: file.Key, Scope: file.Kind, Editable: file.Editable, Downloadable: true, Remark: fileRemark(file)}) + } + sort.SliceStable(entries, func(i int, j int) bool { + if entries[i].Kind == entries[j].Kind { + return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name) + } + return entries[i].Kind == domain.ServerFileEntryDirectory + }) + return entries +} + +func serverFileEntriesFromRunList(content string, fallbackDirectoryKey string, fallbackPath string) ([]domain.ServerFileEntry, error) { + var envelope runFileListEnvelope + if err := json.Unmarshal([]byte(content), &envelope); err != nil { + return nil, err + } + entries := make([]domain.ServerFileEntry, 0, len(envelope.Entries)) + for _, entry := range envelope.Entries { + kind := domain.ServerFileEntryFile + if entry.Kind == string(domain.ServerFileEntryDirectory) { + kind = domain.ServerFileEntryDirectory + } + modifiedAt := time.Time{} + if entry.ModifiedAt != "" { + modifiedAt, _ = time.Parse(time.RFC3339, entry.ModifiedAt) + } + directoryKey := entry.DirectoryKey + if directoryKey == "" { + directoryKey = fallbackDirectoryKey + } + relativePath := cleanServerFileRelativePath(entry.RelativePath) + if relativePath == "" { + relativePath = fallbackPath + } + entries = append(entries, domain.ServerFileEntry{Name: entry.Name, Kind: kind, DirectoryKey: directoryKey, RelativePath: relativePath, LogicalKey: entry.LogicalKey, Scope: entry.Scope, SizeBytes: entry.SizeBytes, ModifiedAt: modifiedAt, Checksum: entry.Checksum, Editable: entry.Editable, Downloadable: entry.Downloadable, Remark: entry.Remark}) + } + return entries, nil +} + +func filterServerFileEntries(entries []domain.ServerFileEntry, query string) []domain.ServerFileEntry { + query = strings.ToLower(strings.TrimSpace(query)) + if query == "" { + return domain.CopyServerFileEntries(entries) + } + filtered := make([]domain.ServerFileEntry, 0, len(entries)) + for _, entry := range entries { + if strings.Contains(strings.ToLower(entry.Name+" "+entry.RelativePath+" "+entry.LogicalKey+" "+entry.Remark), query) { + filtered = append(filtered, entry) + } + } + return filtered +} + +func (svc *CoreService) latestFileListJob(serverInstanceID string, directoryKey string, relativePath string) (domain.Job, bool, error) { + return svc.latestServerFileJob(serverInstanceID, domain.JobCapabilityFilesList, directoryKey) +} + +func (svc *CoreService) latestFileReadJob(serverInstanceID string, key string) (domain.Job, bool, error) { + return svc.latestServerFileJob(serverInstanceID, domain.JobCapabilityFilesRead, key) +} + +func (svc *CoreService) latestServerFileJob(serverInstanceID string, capability string, targetKey string) (domain.Job, bool, error) { + jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: serverInstanceID}) + if err != nil { + return domain.Job{}, false, err + } + var latest domain.Job + found := false + for _, job := range jobs { + if job.Capability != capability || job.TargetKey != targetKey { + continue + } + if !found || job.UpdatedAt.After(latest.UpdatedAt) || job.CreatedAt.After(latest.CreatedAt) { + latest = job + found = true + } + } + return domain.CopyJob(latest), found, nil +} + +func (svc *CoreService) putBrowserFileArtifact(artifact domain.Artifact, payload []byte) error { + svc.artifactMu.Lock() + defer svc.artifactMu.Unlock() + if existing, err := svc.store.Artifacts().Get(artifact.ID); err == nil { + if existing.OwnerKind != artifact.OwnerKind || existing.OwnerID != artifact.OwnerID || existing.SizeBytes != artifact.SizeBytes || existing.Checksum != artifact.Checksum || existing.State != domain.ArtifactStateAvailable { + return validationError("file upload idempotency key conflicts with existing artifact") + } + } else if !errors.Is(err, repo.ErrNotFound) { + return err + } else { + if err := validator.ValidateArtifact(artifact); err != nil { + return err + } + if err := svc.store.Artifacts().Create(artifact); err != nil { + return err + } + } + if err := svc.artifactStore.PutPayload(artifact.ID, payload); err != nil { + return err + } + svc.artifactPayloads[artifact.ID] = domain.CopyBytes(payload) + return nil +} + +func cleanServerFileRelativePath(value string) string { + value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/")) + if value == "" || value == "." { + return "" + } + cleaned := path.Clean(value) + if cleaned == "." { + return "" + } + return strings.TrimPrefix(cleaned, "/") +} + +func serverFileUploadArtifactID(serverInstanceID string, idempotencyKey string, relativePath string) string { + raw := serverInstanceID + ":" + idempotencyKey + ":" + relativePath + return "artifact-file-upload-" + sanitizeIDPart(serverInstanceID) + "-" + fmt.Sprint(stableStringNumber(raw)) +} + +func fileManagerInlineInputRef(serverInstanceID string, key string, idempotencyKey string) string { + return "input://server-files/" + sanitizeIDPart(serverInstanceID) + "/" + sanitizeIDPart(key) + "/" + fmt.Sprint(stableStringNumber(idempotencyKey)) +} + +func fileRemark(file domain.PluginLogicalFile) string { + if file.StreamKey != "" { + return "日志流 " + file.StreamKey + } + if file.Editable { + return "可编辑配置" + } + return "插件声明文件" +} + +func serverFileDisplayName(workspace domain.PluginFileWorkspace, key string) string { + for _, file := range workspace.Files { + if file.Key == key && strings.TrimSpace(file.Label) != "" { + return file.Label + } + } + name := path.Base(key) + if name == "." || name == "/" || name == "" { + return "server-file.txt" + } + return name +} diff --git a/platform/validator/resources.go b/platform/validator/resources.go index 3ec3f4b..093545a 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -1421,6 +1421,104 @@ func ValidateFileOperationDispatchRequest(request domain.FileOperationDispatchRe return finish(violations) } +func ValidateServerFileListRequest(request domain.ServerFileListRequest) error { + var violations []string + violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID) + violations = appendRequired(violations, "directoryKey", request.DirectoryKey) + if !validLogicalFileKey(request.DirectoryKey) { + violations = append(violations, "directoryKey is not allowed") + } + if request.Path != "" && !validLogicalFileKey(request.Path) { + violations = append(violations, "path is not allowed") + } + if len([]rune(request.Query)) > 80 || containsUnsafeRuntimeSecret(request.Query) || looksLikeRawHostPath(request.Query) { + violations = append(violations, "query is not allowed") + } + if request.IdempotencyKey != "" && (containsUnsafeRuntimeSecret(request.IdempotencyKey) || looksLikeRawHostPath(request.IdempotencyKey)) { + violations = append(violations, "idempotencyKey is not allowed") + } + return finish(violations) +} + +func ValidateServerFileReadRequest(request domain.ServerFileReadRequest) error { + return ValidateFileOperationDispatchRequest(domain.FileOperationDispatchRequest{ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: domain.FileOperationRead, Key: request.Key, IdempotencyKey: request.IdempotencyKey}) +} + +func ValidateServerFileWriteRequest(request domain.ServerFileWriteRequest) error { + return ValidateFileOperationDispatchRequest(domain.FileOperationDispatchRequest{ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: domain.FileOperationWrite, Key: request.Key, InputRef: request.InputRef, Content: request.Content, ExpectedConfigVersion: request.ExpectedVersion, ExpectedChecksum: request.ExpectedChecksum, IdempotencyKey: request.IdempotencyKey}) +} + +func ValidateServerFileUploadRequest(request domain.ServerFileUploadRequest) error { + request = domain.CopyServerFileUploadRequest(request) + var violations []string + violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID) + violations = appendRequired(violations, "directoryKey", request.DirectoryKey) + violations = appendRequired(violations, "filename", request.Filename) + violations = appendRequired(violations, "checksum", request.Checksum) + violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey) + if !validLogicalFileKey(request.DirectoryKey) { + violations = append(violations, "directoryKey is not allowed") + } + if request.RelativePath != "" && !validLogicalFileKey(request.RelativePath) { + violations = append(violations, "relativePath is not allowed") + } + if !validUploadFilename(request.Filename) { + violations = append(violations, "filename is not allowed") + } + if len(request.Payload) == 0 { + violations = append(violations, "payload is required") + } + if int64(len(request.Payload)) > MaxArtifactBytes { + violations = append(violations, fmt.Sprintf("payload must not exceed %d", MaxArtifactBytes)) + } + if request.Checksum != "" { + if !validSHA256Checksum(request.Checksum) { + violations = append(violations, "checksum must be sha256:") + } else if request.Checksum != BytesChecksum(request.Payload) { + violations = append(violations, "checksum does not match payload") + } + } + for _, value := range []string{request.ServerInstanceID, request.DirectoryKey, request.RelativePath, request.Filename, request.IdempotencyKey} { + if containsUnsafeRuntimeSecret(value) || looksLikeRawHostPath(value) || strings.Contains(strings.ToLower(value), "unix://") { + violations = append(violations, "request contains unsafe content") + break + } + } + return finish(violations) +} + +func ValidateServerFileDownloadRequest(request domain.ServerFileDownloadRequest) error { + var violations []string + violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID) + violations = appendRequired(violations, "key", request.Key) + violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey) + if !validLogicalFileKey(request.Key) { + violations = append(violations, "key is not allowed") + } + if containsUnsafeRuntimeSecret(request.IdempotencyKey) || looksLikeRawHostPath(request.IdempotencyKey) { + violations = append(violations, "idempotencyKey is not allowed") + } + return finish(violations) +} + +func ValidateRunFileInputChunkRequest(request domain.RunFileInputChunkRequest) error { + var violations []string + violations = appendRequired(violations, "runEndpointId", request.RunEndpointID) + violations = appendRequired(violations, "sessionToken", request.SessionToken) + violations = appendRequired(violations, "jobId", request.JobID) + violations = appendRequired(violations, "leaseToken", request.LeaseToken) + if request.Attempt <= 0 { + violations = append(violations, "attempt must be positive") + } + if request.Offset < 0 { + violations = append(violations, "offset must not be negative") + } + if request.Length <= 0 || request.Length > MaxArtifactDownloadBytes { + violations = append(violations, fmt.Sprintf("length must be between 1 and %d", MaxArtifactDownloadBytes)) + } + return finish(violations) +} + func appendPercentViolation(violations []string, field string, value float64) []string { if value < 0 || value > 100 { return append(violations, field+" must be between 0 and 100") @@ -1588,7 +1686,7 @@ func ValidateJob(job domain.Job) error { if len(job.ExecutionResult.Summary) > maxSummaryLength { violations = append(violations, "executionResult.summary is too long") } - if job.Capability == domain.JobCapabilityConfigWrite || job.Capability == domain.JobCapabilityFilesRead || job.Capability == domain.JobCapabilityFilesWrite { + if job.Capability == domain.JobCapabilityConfigWrite || job.Capability == domain.JobCapabilityFilesList || job.Capability == domain.JobCapabilityFilesRead || job.Capability == domain.JobCapabilityFilesWrite { if job.ServerInstanceID == "" { violations = append(violations, "serverInstanceId is required for scoped file jobs") } @@ -2305,13 +2403,27 @@ func validRemoteDatabaseEngine(engine string) bool { func validFileOperationKind(operation domain.FileOperationKind) bool { switch operation { - case domain.FileOperationRead, domain.FileOperationWrite: + case domain.FileOperationList, domain.FileOperationRead, domain.FileOperationWrite: return true default: return false } } +func validUploadFilename(name string) bool { + trimmed := strings.TrimSpace(name) + if trimmed == "" || trimmed != name || len([]rune(name)) > 120 || strings.Contains(name, "/") || strings.Contains(name, `\`) || strings.Contains(name, "..") || strings.Contains(name, "://") || looksLikeRawHostPath(name) || containsUnsafeRuntimeSecret(name) { + return false + } + for _, char := range name { + if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '.' || char == ' ' || char == '(' || char == ')' { + continue + } + return false + } + return true +} + func validConfigFileKey(key string) bool { switch key { case "server.properties", "config/server.properties": diff --git a/platform_web/api/client.test.ts b/platform_web/api/client.test.ts index 9477bcf..eaf85f3 100644 --- a/platform_web/api/client.test.ts +++ b/platform_web/api/client.test.ts @@ -144,6 +144,18 @@ const runtimeDownload: ArtifactDownloadReferenceResponse = { storageBehavior: "platform-memory-transfer-session" }; +const serverFileWorkspace = { + serverInstanceId: server.id, + pluginId: plugin.id, + defaultDirectoryKey: "configs", + directories: [{ key: "configs", label: "配置", scope: "config" }], + files: [{ key: "config/server.properties", directoryKey: "configs", label: "server.properties", kind: "config", editable: true }], + configFields: [], + transfer: { channel: "run-file-transfer", uploadChunkSizeBytes: 1048576, downloadChunkSizeBytes: 1048576, maxInlineEditBytes: 65536, maxBrowserUploadBytes: 52428800 }, + declaredOnly: true, + runtimeWorkspaceScope: "server-runtime" +}; + describe("PlatformApiClient AI providers", () => { afterEach(() => { setPlatformApiSessionToken(null); @@ -259,6 +271,40 @@ describe("PlatformApiClient AI providers", () => { job: { ...job, id: "job-file-read", capability: "files.read", targetKey: "logs/latest.log" } }); } + if (url.endsWith("/api/v1/server-instances/server-1/files/workspace") && (!init?.method || init.method === "GET")) { + return jsonResponse(serverFileWorkspace); + } + if (url.endsWith("/api/v1/server-instances/server-1/files/list?directoryKey=configs&query=server&recursive=true")) { + return jsonResponse({ serverInstanceId: server.id, pluginId: plugin.id, directoryKey: "configs", state: "declared", entries: [{ name: "server.properties", kind: "file", directoryKey: "configs", relativePath: "config/server.properties", logicalKey: "config/server.properties", scope: "config", sizeBytes: 42, editable: true, downloadable: true, remark: "配置文件" }], reason: "declared" }); + } + if (url.endsWith("/api/v1/server-instances/server-1/files/refresh") && init?.method === "POST") { + expect(JSON.parse(String(init.body))).toEqual({ directoryKey: "configs", query: "server", recursive: true, idempotencyKey: "idem-file-list" }); + return jsonResponse({ serverInstanceId: server.id, pluginId: plugin.id, directoryKey: "configs", state: "pending", entries: [], job: { ...job, id: "job-file-list", capability: "files.list", targetKey: "configs" }, reason: "queued" }); + } + if (url.endsWith("/api/v1/server-instances/server-1/files/read-snapshot?key=config%2Fserver.properties")) { + return jsonResponse({ serverInstanceId: server.id, pluginId: plugin.id, key: "config/server.properties", state: "ready", content: "server.name=Example\n", version: 3, checksum: "sha256:filechecksum", sizeBytes: 20, readAt: "2026-07-03T00:00:00Z" }); + } + if (url.endsWith("/api/v1/server-instances/server-1/files/read") && init?.method === "POST") { + expect(JSON.parse(String(init.body))).toEqual({ key: "config/server.properties", idempotencyKey: "idem-file-read" }); + return jsonResponse({ status: "queued", serverInstanceId: server.id, pluginId: plugin.id, operation: "read", key: "config/server.properties", job: { ...job, id: "job-server-file-read", capability: "files.read", targetKey: "config/server.properties" } }); + } + if (url.endsWith("/api/v1/server-instances/server-1/files/write") && init?.method === "POST") { + expect(JSON.parse(String(init.body))).toEqual({ key: "config/server.properties", content: "server.name=Example\n", expectedVersion: 3, expectedChecksum: "sha256:filechecksum", idempotencyKey: "idem-file-write" }); + return jsonResponse({ status: "queued", serverInstanceId: server.id, pluginId: plugin.id, operation: "write", key: "config/server.properties", job: { ...job, id: "job-server-file-write", capability: "files.write", targetKey: "config/server.properties" } }); + } + if (url.endsWith("/api/v1/server-instances/server-1/files/upload") && init?.method === "POST") { + const body = init.body as FormData; + expect(body.get("directoryKey")).toBe("configs"); + expect(body.get("relativePath")).toBe(""); + expect(body.get("filename")).toBe("server.properties"); + expect(body.get("idempotencyKey")).toBe("idem-file-upload"); + expect(body.get("file")).toBeInstanceOf(File); + return jsonResponse({ status: "queued", serverInstanceId: server.id, directoryKey: "configs", relativePath: "server.properties", artifactId: "artifact-upload-1", inputRef: "artifact://artifact-upload-1", sizeBytes: 20, checksum: "sha256:uploadchecksum", job: { ...job, id: "job-server-file-upload", capability: "files.write", targetKey: "configs/server.properties" } }); + } + if (url.endsWith("/api/v1/server-instances/server-1/files/download") && init?.method === "POST") { + expect(JSON.parse(String(init.body))).toEqual({ key: "config/server.properties", idempotencyKey: "idem-file-download" }); + return jsonResponse({ status: "ready", serverInstanceId: server.id, key: "config/server.properties", filename: "server.properties", contentType: "text/plain; charset=utf-8", content: "server.name=Example\n", checksum: "sha256:filechecksum", sizeBytes: 20, readAt: "2026-07-03T00:00:00Z" }); + } if (url.endsWith("/api/v1/run/endpoints")) { return jsonResponse({ items: [endpoint], count: 1 }); } @@ -546,6 +592,14 @@ describe("PlatformApiClient AI providers", () => { status: "queued", job: { capability: "files.read", targetKey: "logs/latest.log" } }); + await expect(client.getServerFileWorkspace(server.id)).resolves.toMatchObject({ defaultDirectoryKey: "configs", transfer: { channel: "run-file-transfer" } }); + await expect(client.listServerFiles(server.id, { directoryKey: "configs", query: "server", recursive: true })).resolves.toMatchObject({ state: "declared", entries: [{ logicalKey: "config/server.properties" }] }); + await expect(client.refreshServerFiles(server.id, { directoryKey: "configs", query: "server", recursive: true, idempotencyKey: "idem-file-list" })).resolves.toMatchObject({ state: "pending", job: { capability: "files.list" } }); + await expect(client.getServerFileReadSnapshot(server.id, "config/server.properties")).resolves.toMatchObject({ state: "ready", content: "server.name=Example\n" }); + await expect(client.readServerFile(server.id, { key: "config/server.properties", idempotencyKey: "idem-file-read" })).resolves.toMatchObject({ operation: "read", job: { capability: "files.read" } }); + await expect(client.writeServerFile(server.id, { key: "config/server.properties", content: "server.name=Example\n", expectedVersion: 3, expectedChecksum: "sha256:filechecksum", idempotencyKey: "idem-file-write" })).resolves.toMatchObject({ operation: "write", job: { capability: "files.write" } }); + await expect(client.uploadServerFile(server.id, { directoryKey: "configs", file: new File(["server.name=Example\n"], "server.properties", { type: "text/plain" }), idempotencyKey: "idem-file-upload" })).resolves.toMatchObject({ inputRef: "artifact://artifact-upload-1", job: { capability: "files.write" } }); + await expect(client.prepareServerFileDownload(server.id, { key: "config/server.properties", idempotencyKey: "idem-file-download" })).resolves.toMatchObject({ status: "ready", filename: "server.properties", content: "server.name=Example\n" }); await expect(client.listRunEndpoints()).resolves.toMatchObject({ count: 1 }); await expect(client.listJobs()).resolves.toMatchObject({ count: 1 }); await expect(client.listJobs(server.id)).resolves.toMatchObject({ count: 1 }); @@ -600,7 +654,7 @@ describe("PlatformApiClient AI providers", () => { client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" }) ).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "review required" } }); - expect(fetchMock).toHaveBeenCalledTimes(36); + expect(fetchMock).toHaveBeenCalledTimes(44); }); it("calls plugin marketplace endpoints with filter and state contracts", async () => { diff --git a/platform_web/api/client.ts b/platform_web/api/client.ts index 7f8a998..13b100c 100644 --- a/platform_web/api/client.ts +++ b/platform_web/api/client.ts @@ -32,6 +32,7 @@ import type { CurrentUserResponse, DependencyCatalogResponse, DependencyJobRequest, + DeclaredFileReadSnapshotResponse, FileOperationDispatchRequest, FileOperationDispatchResponse, GameClientBridgeCancelRequest, @@ -84,6 +85,14 @@ import type { ServerDeploymentResponse, ServerInstanceListResponse, ServerDeletionRequest, + ServerFileDownloadRequest, + ServerFileDownloadResponse, + ServerFileListRequest, + ServerFileListResponse, + ServerFileReadRequest, + ServerFileUploadResponse, + ServerFileWorkspaceResponse, + ServerFileWriteRequest, ServerInstanceUpdateRequest, ServerInstanceResponse, ServerMemberListResponse, @@ -575,6 +584,51 @@ export class PlatformApiClient { }); } + async getServerFileWorkspace(serverInstanceId: string): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/workspace`); + } + + async listServerFiles(serverInstanceId: string, request: Partial = {}): Promise { + const params = serverFileListQuery(request); + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/list${params}`); + } + + async refreshServerFiles(serverInstanceId: string, request: ServerFileListRequest): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/refresh`, { method: "POST", body: request }); + } + + async readServerFile(serverInstanceId: string, request: ServerFileReadRequest): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/read`, { method: "POST", body: request }); + } + + async getServerFileReadSnapshot(serverInstanceId: string, key: string): Promise { + const params = new URLSearchParams({ key }); + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/read-snapshot?${params.toString()}`); + } + + async writeServerFile(serverInstanceId: string, request: ServerFileWriteRequest): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/write`, { method: "POST", body: request }); + } + + async uploadServerFile(serverInstanceId: string, request: { directoryKey: string; relativePath?: string; file: File; idempotencyKey: string }): Promise { + const body = new FormData(); + body.set("directoryKey", request.directoryKey); + body.set("relativePath", request.relativePath ?? ""); + body.set("filename", request.file.name); + body.set("idempotencyKey", request.idempotencyKey); + body.set("file", request.file); + const headers = new Headers(); + const sessionToken = this.sessionTokenProvider(); + if (sessionToken) headers.set("Authorization", `Bearer ${sessionToken}`); + const response = await fetch(`${this.baseUrl}/server-instances/${encodeURIComponent(serverInstanceId)}/files/upload`, { method: "POST", credentials: "same-origin", headers, body }); + if (!response.ok) throw await responseError(response); + return response.json() as Promise; + } + + async prepareServerFileDownload(serverInstanceId: string, request: ServerFileDownloadRequest): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/download`, { method: "POST", body: request }); + } + async listLogStreams(serverInstanceId?: string): Promise { const query = serverInstanceId ? `?serverInstanceId=${encodeURIComponent(serverInstanceId)}` : ""; return this.request(`/log-streams${query}`); @@ -900,4 +954,14 @@ function artifactQuery(filter: ArtifactFilterRequest): string { return query ? `?${query}` : ""; } +function serverFileListQuery(request: Partial): string { + const params = new URLSearchParams(); + if (request.directoryKey) params.set("directoryKey", request.directoryKey); + if (request.path) params.set("path", request.path); + if (request.query) params.set("query", request.query); + if (request.recursive) params.set("recursive", "true"); + const query = params.toString(); + return query ? `?${query}` : ""; +} + export const platformApiClient = new PlatformApiClient(readWebRuntimeEnv().platformApiBaseUrl); diff --git a/platform_web/api/contracts.md b/platform_web/api/contracts.md index c74bd94..67eaf5c 100644 --- a/platform_web/api/contracts.md +++ b/platform_web/api/contracts.md @@ -27,7 +27,8 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia - `startServerInstance` and `stopServerInstance` post `ServerLifecycleCommandRequest` with the current config version and receive the lifecycle job response. - `listServerAdministratorCandidates`, `addServerAdministrator`, and `removeServerAdministrator` call server membership endpoints so server owners can invite or remove active non-platform-admin server administrators. - Game-specific pages use the scoped `plugin-data` collection API and declared plugin bridge machine actions; Platform does not expose game-specific projection or workflow clients. -- `dispatchFileOperation` posts `FileOperationDispatchRequest` to `/file-operations/dispatch` using logical file keys and scoped refs rather than raw host paths; it is not wired into SCUM server-detail/plugin pages as a raw file workbench. +- `dispatchFileOperation` posts `FileOperationDispatchRequest` to `/file-operations/dispatch` using logical file keys and scoped refs rather than raw host paths; it remains the low-level compatibility dispatch for plugin-declared file work. +- `getServerFileWorkspace`, `listServerFiles`, `refreshServerFiles`, `readServerFile`, `getServerFileReadSnapshot`, `writeServerFile`, `uploadServerFile`, and `prepareServerFileDownload` power the first-party server-detail file manager. The page renders plugin-declared logical directories, requests live listings through `files.list`, reads editable snapshots through `files.read`, saves through `files.write`, and stages browser uploads as server-instance artifacts before Run pulls input chunks on the dedicated file-transfer channel. Server detail may call only these server-file APIs plus the encapsulated download helper; it must not call raw artifact-transfer methods directly. - `listArtifacts`, `openArtifactDownload`, and `readArtifactContent` use platform artifact routes for available job/server artifacts. Browser reads are chunked through `/artifacts/{id}/content` and must render only safe filenames, checksums, progress, and platform storage behavior. - `authorizePluginBridge` posts `PluginBridgeAuthorizeRequest` to `/plugin-bridge/authorize` for preflight decisions. - `executePluginBridge` posts `PluginBridgeExecuteRequest` to `/plugin-bridge/execute` from host-owned bridge dispatch utilities only. Plugin pages receive typed `PluginBridgeExecuteResponse` envelopes and never receive the platform API client, bearer token, raw provider key, run socket, host path, or storage credential. diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index 220fdeb..180eba2 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -1421,7 +1421,7 @@ export interface ServerConfigWriteDispatchResponse { job: JobResponse; } -export type FileOperationKind = "read" | "write"; +export type FileOperationKind = "list" | "read" | "write"; export interface FileOperationDispatchRequest { serverInstanceId: string; @@ -1459,6 +1459,112 @@ export interface DeclaredFileReadSnapshotResponse { reason?: string; } +export interface ServerFileTransferPolicyResponse { + channel: string; + uploadChunkSizeBytes: number; + downloadChunkSizeBytes: number; + maxInlineEditBytes: number; + maxBrowserUploadBytes: number; + notes?: string[]; +} + +export interface ServerFileWorkspaceResponse { + serverInstanceId: string; + pluginId: string; + defaultDirectoryKey: string; + directories: PluginLogicalDirectoryResponse[]; + files: PluginLogicalFileResponse[]; + configFields: PluginConfigFieldResponse[]; + transfer: ServerFileTransferPolicyResponse; + declaredOnly: boolean; + runtimeWorkspaceScope?: string; +} + +export type ServerFileEntryKind = "directory" | "file"; + +export interface ServerFileEntryResponse { + name: string; + kind: ServerFileEntryKind; + directoryKey: string; + relativePath?: string; + logicalKey?: string; + scope?: string; + sizeBytes?: number; + modifiedAt?: string; + checksum?: string; + editable: boolean; + downloadable: boolean; + remark?: string; +} + +export interface ServerFileListRequest { + directoryKey: string; + path?: string; + query?: string; + recursive?: boolean; + idempotencyKey?: string; +} + +export interface ServerFileListResponse { + serverInstanceId: string; + pluginId: string; + directoryKey: string; + path?: string; + state: "ready" | "pending" | "declared" | string; + entries: ServerFileEntryResponse[]; + job?: JobResponse; + refreshedAt?: string; + reason?: string; +} + +export interface ServerFileReadRequest { + pluginId?: string; + key: string; + idempotencyKey: string; +} + +export interface ServerFileWriteRequest { + pluginId?: string; + key: string; + content?: string; + inputRef?: string; + expectedVersion?: number; + expectedChecksum?: string; + idempotencyKey: string; +} + +export interface ServerFileUploadResponse { + status: string; + serverInstanceId: string; + directoryKey: string; + relativePath: string; + artifactId: string; + inputRef: string; + sizeBytes: number; + checksum: string; + job: JobResponse; +} + +export interface ServerFileDownloadRequest { + key: string; + idempotencyKey: string; +} + +export interface ServerFileDownloadResponse { + status: "ready" | "pending" | string; + serverInstanceId: string; + key: string; + filename: string; + contentType?: string; + content?: string; + checksum?: string; + sizeBytes?: number; + artifact?: ArtifactDownloadReferenceResponse; + job?: JobResponse; + readAt?: string; + reason?: string; +} + export interface LogStreamResponse { id: string; serverInstanceId: string; diff --git a/platform_web/contracts/workspace.ts b/platform_web/contracts/workspace.ts index 82c4ac7..8be4da4 100644 --- a/platform_web/contracts/workspace.ts +++ b/platform_web/contracts/workspace.ts @@ -144,10 +144,11 @@ export interface PlatformOverviewSignal { at: string; } -export type ServerDetailSection = "manage" | "llm" | `plugin:${string}`; +export type ServerDetailSection = "manage" | "files" | "llm" | `plugin:${string}`; export const serverDetailSections: Array<{ id: ServerDetailSection; label: string }> = [ { id: "manage", label: "管理" }, + { id: "files", label: "文件" }, { id: "llm", label: "AI 助手" } ]; diff --git a/platform_web/pages/ServerDetailPage.test.tsx b/platform_web/pages/ServerDetailPage.test.tsx index 1e8675f..312011d 100644 --- a/platform_web/pages/ServerDetailPage.test.tsx +++ b/platform_web/pages/ServerDetailPage.test.tsx @@ -64,6 +64,9 @@ describe("ServerDetailPage config write approval", () => { }); it("keeps artifact transfer and backend internals out of server detail", () => { + expect(serverDetailPageSource).toContain("ServerFilesSection"); + expect(serverDetailPageSource).toContain("getServerFileWorkspace"); + expect(serverDetailPageSource).toContain("downloadServerFileResult"); expect(serverDetailPageSource).not.toContain("openArtifactDownload"); expect(serverDetailPageSource).not.toContain("readArtifactContent"); expect(serverDetailPageSource).not.toContain("浏览器制品传输"); @@ -130,6 +133,7 @@ describe("ServerDetailPage config write approval", () => { it("routes plugin-declared pages into server detail tabs", () => { expect(serverDetailPageSource).toContain("serverDetailSectionEntries(readyPlugin)"); expect(serverDetailPageSource).toContain("plugin:${page.key}"); + expect(serverDetailPageSource).toContain('section === "files"'); expect(serverDetailPageSource).toContain("PluginPageSection"); expect(serverDetailPageSource).toContain("PluginPageHostPage"); expect(serverDetailPageSource).not.toContain("ScumFileManagementSection"); diff --git a/platform_web/pages/ServerDetailPage.tsx b/platform_web/pages/ServerDetailPage.tsx index b68acf3..976c093 100644 --- a/platform_web/pages/ServerDetailPage.tsx +++ b/platform_web/pages/ServerDetailPage.tsx @@ -1,9 +1,10 @@ -import { MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, Terminal, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react"; -import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react"; +import { ChevronRight, Download, Eye, FileText, Folder, MoonStar, PackageOpen, Pencil, RefreshCw, Save, Search, ShieldCheck, Sparkles, Square, Terminal, Upload, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react"; +import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useState } from "react"; import { platformApiClient } from "../api/client"; import type { ConfigDiffLineResponse, + DeclaredFileReadSnapshotResponse, GamePluginResponse, JobResponse, ServerInstanceResponse, @@ -11,7 +12,10 @@ import type { ServerMetricsResponse, ServerDeploymentResponse, ServerConfigDiffPreviewResponse, - RunEndpointResponse + RunEndpointResponse, + ServerFileEntryResponse, + ServerFileListResponse, + ServerFileWorkspaceResponse } from "../api/types"; import { ConfirmDialog, UsageMeter } from "../components/OperationControls"; import { ServerManagementTerminalDrawer } from "../components/ServerManagementTerminalDrawer"; @@ -30,6 +34,7 @@ import { serverMetadataUpdateRequestFromForm } from "../schemas/serverManagement"; import { cx } from "../utils/classes"; +import { downloadServerFileResult } from "../utils/serverFileTransfer"; import { stateLabel, statusClass } from "./ServersPage"; import { PluginPageHostPage } from "./PluginPageHostPage"; @@ -281,6 +286,7 @@ export function ServerDetailPage(props: PageComponentProps) { /> )} {section === "manage" && setInstance({ status: "ready", data: next })} />} + {section === "files" && } {section === "llm" && } setTerminalOpen(false)} /> @@ -560,6 +566,321 @@ function ServerAdministratorsSection({ instance, session, onChanged }: ServerAdm ); } +interface ServerFilesSectionProps { + instance: ServerInstanceResponse; + session: PageComponentProps["session"]; + operations: PageComponentProps["operations"]; +} + +interface ServerFileEditorState { + entry: ServerFileEntryResponse | null; + key: string; + draft: string; + snapshot?: DeclaredFileReadSnapshotResponse; + loading: boolean; + saving: boolean; + message?: string; + error?: string; +} + +function ServerFilesSection({ instance, session, operations }: ServerFilesSectionProps) { + const [workspace, setWorkspace] = useState>({ status: "loading" }); + const [list, setList] = useState>({ status: "loading" }); + const [directoryKey, setDirectoryKey] = useState(""); + const [relativePath, setRelativePath] = useState(""); + const [searchDraft, setSearchDraft] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); + const [recursive, setRecursive] = useState(false); + const [panelResult, setPanelResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null); + const [uploadBusy, setUploadBusy] = useState(false); + const [editor, setEditor] = useState({ entry: null, key: "", draft: "", loading: false, saving: false }); + + const activeDirectory = workspace.status === "ready" ? workspace.data.directories.find((item) => item.key === directoryKey) : undefined; + const canUpload = workspace.status === "ready" && activeDirectory?.scope !== "logs" && !uploadBusy; + const entries = list.status === "ready" ? list.data.entries : []; + + const loadWorkspace = useCallback(async () => { + setWorkspace({ status: "loading" }); + try { + const response = await platformApiClient.getServerFileWorkspace(instance.id); + setWorkspace({ status: "ready", data: response }); + setDirectoryKey((current) => current || response.defaultDirectoryKey || response.directories[0]?.key || ""); + } catch (error) { + setWorkspace({ status: "error", reason: error instanceof Error ? error.message : "文件工作区加载失败" }); + setList({ status: "error", reason: "文件工作区不可用" }); + } + }, [instance.id]); + + const loadList = useCallback(async () => { + if (!directoryKey) return; + setList({ status: "loading" }); + try { + const response = await platformApiClient.listServerFiles(instance.id, { directoryKey, path: relativePath || undefined, query: searchQuery || undefined, recursive }); + setList({ status: "ready", data: response }); + } catch (error) { + setList({ status: "error", reason: error instanceof Error ? error.message : "文件列表加载失败" }); + } + }, [directoryKey, instance.id, recursive, relativePath, searchQuery]); + + useEffect(() => { + void loadWorkspace(); + }, [loadWorkspace]); + + useEffect(() => { + if (workspace.status !== "ready" || !directoryKey) return; + void loadList(); + }, [directoryKey, loadList, workspace.status]); + + async function refreshRuntimeList() { + if (!directoryKey) return; + const operationId = operations.begin({ intent: "刷新文件目录", targetKind: "server", targetId: instance.id, requester: session.displayName }); + setPanelResult({ status: "pending", label: "正在向 Run 请求实时目录…" }); + try { + const response = await platformApiClient.refreshServerFiles(instance.id, { directoryKey, path: relativePath || undefined, query: searchQuery || undefined, recursive, idempotencyKey: serverFileIdempotency("list", instance.id, directoryKey) }); + setList({ status: "ready", data: response }); + operations.succeed(operationId, `目录刷新任务 ${response.job?.id ?? "已派发"}`, response.job); + setPanelResult({ status: "pending", label: response.reason ?? "目录刷新任务已派发,稍后可再次刷新查看实时结果。" }); + } catch (error) { + const reason = error instanceof Error ? error.message : "目录刷新失败"; + operations.fail(operationId, reason, operationId); + setPanelResult({ status: "failed", label: reason }); + } + } + + async function openEntry(entry: ServerFileEntryResponse) { + if (entry.kind === "directory") { + setDirectoryKey(entry.directoryKey || directoryKey); + setRelativePath(entry.relativePath ?? ""); + setEditor({ entry: null, key: "", draft: "", loading: false, saving: false }); + return; + } + await openEditor(entry); + } + + async function openEditor(entry: ServerFileEntryResponse) { + const key = serverFileEntryKey(entry); + if (!key) { + setPanelResult({ status: "failed", label: "该文件缺少插件声明的逻辑 key,不能读取。" }); + return; + } + setEditor({ entry, key, draft: "", loading: true, saving: false, message: "正在读取最近快照…" }); + try { + const snapshot = await platformApiClient.getServerFileReadSnapshot(instance.id, key); + if (snapshot.state === "ready") { + setEditor({ entry, key, draft: snapshot.content ?? "", snapshot, loading: false, saving: false, message: snapshot.content === undefined ? snapshot.reason ?? "文件快照已就绪,但内容不适合内联编辑。" : "已加载最近读取快照。" }); + return; + } + const operationId = operations.begin({ intent: "读取文件", targetKind: "server", targetId: instance.id, requester: session.displayName }); + const dispatch = await platformApiClient.readServerFile(instance.id, { key, idempotencyKey: serverFileIdempotency("read", instance.id, key) }); + operations.succeed(operationId, `读取任务 ${dispatch.job.id} 已派发`, dispatch.job); + setEditor({ entry, key, draft: "", snapshot, loading: false, saving: false, message: snapshot.reason ?? "读取任务已派发;Run 返回后再次打开即可编辑。" }); + setPanelResult({ status: "pending", label: `读取任务已派发:${dispatch.job.id}` }); + } catch (error) { + const reason = error instanceof Error ? error.message : "文件读取失败"; + setEditor({ entry, key, draft: "", loading: false, saving: false, error: reason }); + setPanelResult({ status: "failed", label: reason }); + } + } + + async function saveEditor() { + if (!editor.entry || !editor.key || editor.saving || !editor.entry.editable) return; + const operationId = operations.begin({ intent: "保存文件", targetKind: "server", targetId: instance.id, requester: session.displayName }); + setEditor((current) => ({ ...current, saving: true, error: undefined, message: "正在派发写入任务…" })); + try { + const dispatch = await platformApiClient.writeServerFile(instance.id, { key: editor.key, content: editor.draft, expectedVersion: editor.snapshot?.version, expectedChecksum: editor.snapshot?.checksum, idempotencyKey: serverFileIdempotency("write", instance.id, editor.key) }); + operations.succeed(operationId, `写入任务 ${dispatch.job.id} 已派发`, dispatch.job); + setEditor((current) => ({ ...current, saving: false, message: "保存任务已派发;Run 会在工作区内原子写入。" })); + setPanelResult({ status: "pending", label: `写入任务已派发:${dispatch.job.id}` }); + await loadList(); + } catch (error) { + const reason = error instanceof Error ? error.message : "文件保存失败"; + operations.fail(operationId, reason, operationId); + setEditor((current) => ({ ...current, saving: false, error: reason })); + setPanelResult({ status: "failed", label: reason }); + } + } + + async function downloadEntry(entry: ServerFileEntryResponse) { + const key = serverFileEntryKey(entry); + if (!key || !entry.downloadable) return; + const operationId = operations.begin({ intent: "下载文件", targetKind: "server", targetId: instance.id, requester: session.displayName }); + setPanelResult({ status: "pending", label: "正在准备文件下载…" }); + try { + const result = await platformApiClient.prepareServerFileDownload(instance.id, { key, idempotencyKey: serverFileIdempotency("download", instance.id, key) }); + const message = await downloadServerFileResult(platformApiClient, result); + operations.succeed(operationId, message, result.job); + setPanelResult({ status: result.status === "ready" ? "succeeded" : "pending", label: message }); + } catch (error) { + const reason = error instanceof Error ? error.message : "文件下载失败"; + operations.fail(operationId, reason, operationId); + setPanelResult({ status: "failed", label: reason }); + } + } + + async function uploadFile(event: ChangeEvent) { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file || !directoryKey || workspace.status !== "ready") return; + if (file.size > workspace.data.transfer.maxBrowserUploadBytes) { + setPanelResult({ status: "failed", label: `文件超过浏览器上传上限:${formatBytes(workspace.data.transfer.maxBrowserUploadBytes)}` }); + return; + } + const operationId = operations.begin({ intent: "上传文件", targetKind: "server", targetId: instance.id, requester: session.displayName }); + setUploadBusy(true); + setPanelResult({ status: "pending", label: `正在暂存上传:${file.name}` }); + try { + const response = await platformApiClient.uploadServerFile(instance.id, { directoryKey, relativePath: relativePath || undefined, file, idempotencyKey: serverFileIdempotency("upload", instance.id, file.name) }); + operations.succeed(operationId, `上传已暂存,写入任务 ${response.job.id} 已派发`, response.job); + setPanelResult({ status: "pending", label: `上传已走独立文件通道排队:${response.relativePath}` }); + await loadList(); + } catch (error) { + const reason = error instanceof Error ? error.message : "文件上传失败"; + operations.fail(operationId, reason, operationId); + setPanelResult({ status: "failed", label: reason }); + } finally { + setUploadBusy(false); + } + } + + function submitSearch(event: FormEvent) { + event.preventDefault(); + setSearchQuery(searchDraft.trim()); + } + + function goUp() { + if (relativePath) { + setRelativePath(relativePath.split("/").filter(Boolean).slice(0, -1).join("/")); + return; + } + if (workspace.status === "ready" && directoryKey !== workspace.data.defaultDirectoryKey) { + setDirectoryKey(workspace.data.defaultDirectoryKey || workspace.data.directories[0]?.key || directoryKey); + } + } + + if (workspace.status === "loading") return ; + if (workspace.status === "error") return void loadWorkspace()} compact />; + + return ( +
+
+

服务器文件

+ 独立通道 {workspace.data.transfer.channel} · 内联编辑 {formatBytes(workspace.data.transfer.maxInlineEditBytes)} +
+
+ + {activeDirectory?.label ?? (directoryKey || "未声明目录")} + {relativePath.split("/").filter(Boolean).map((part) => {part})} +
+
+
+ {workspace.data.directories.map((directory) => ( + + ))} +
+
+ + setSearchDraft(event.target.value)} /> + + + +
+ + +
+
+ {panelResult && } + {list.status === "loading" && } + {list.status === "error" && void loadList()} compact />} + {list.status === "ready" && ( +
+
+ + + + {entries.length === 0 && } + {entries.map((entry) => ( + + + + + + + + + ))} + +
文件名称大小修改时间备注操作
当前目录没有可展示文件;可刷新实时目录或换一个插件声明目录。
+ + {entry.logicalKey || entry.relativePath || entry.directoryKey} + {entry.kind === "directory" ? "计算" : formatBytes(entry.sizeBytes)}{formatDateTime(entry.modifiedAt)}{entry.remark || entry.scope || "--"} +
+ {entry.kind === "directory" ? : } + {entry.kind === "file" && } +
+
+
+