diff --git a/platform/api/resource_handlers.go b/platform/api/resource_handlers.go index af28cb9..8b9d578 100644 --- a/platform/api/resource_handlers.go +++ b/platform/api/resource_handlers.go @@ -1290,7 +1290,7 @@ func (h *coreHandlers) serverInstanceConfigApprove(w http.ResponseWriter, r *htt // serverFilesWorkspace godoc // @Summary Read server file workspace -// @Description Returns plugin-declared logical directories and transfer policy without exposing host paths. +// @Description Returns the generic server file manager root and transfer policy without exposing host paths. // @Tags server-files // @Produce json // @Param id path string true "Server instance ID" diff --git a/platform/api/resource_handlers_test.go b/platform/api/resource_handlers_test.go index d10df59..73bedde 100644 --- a/platform/api/resource_handlers_test.go +++ b/platform/api/resource_handlers_test.go @@ -260,18 +260,12 @@ func TestCoreAPIServerFileWorkspaceRoutesAreScoped(t *testing.T) { putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, adminSession) 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 { + if workspace.DefaultDirectoryKey != "server-root" || workspace.Transfer.Channel != "run-file-transfer" || workspace.DeclaredOnly || len(workspace.Directories) != 1 || workspace.Directories[0].Label != "服务器根目录" || len(workspace.Files) != 0 { 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) + list := getJSONWithAuth[dto.ServerFileListResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/list?directoryKey=server-root", adminSession) + if list.State != "declared" || list.DirectoryKey != "server-root" || list.Entries == nil || !strings.Contains(list.Reason, "服务器文件缓存") { + t.Fatalf("expected generic root 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) @@ -300,11 +294,11 @@ func TestCoreAPIServerFileWorkspaceSynthesizesDefaultDirectoryForLegacyPlugin(t instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-file-legacy-api", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Legacy File API Server", State: domain.ServerInstanceStateRunning}, adminSession) workspace := getJSONWithAuth[dto.ServerFileWorkspaceResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/workspace", adminSession) - if workspace.DefaultDirectoryKey != "server-files" || workspace.DeclaredOnly || len(workspace.Directories) == 0 || workspace.Directories[0].Label != "服务器文件" || workspace.Directories == nil || workspace.Files == nil || workspace.ConfigFields == nil { + if workspace.DefaultDirectoryKey != "server-root" || workspace.DeclaredOnly || len(workspace.Directories) != 1 || workspace.Directories[0].Label != "服务器根目录" || workspace.Directories == nil || workspace.Files == nil || workspace.ConfigFields == nil { t.Fatalf("expected synthesized non-null workspace, got %+v", workspace) } list := getJSONWithAuth[dto.ServerFileListResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/list", adminSession) - if list.DirectoryKey != "server-files" || list.Entries == nil || !strings.Contains(list.Reason, "通用服务器文件目录") { + if list.DirectoryKey != "server-root" || list.Entries == nil || !strings.Contains(list.Reason, "服务器文件缓存") { t.Fatalf("expected default file list, got %+v", list) } } diff --git a/platform/service/resources.go b/platform/service/resources.go index 2a2c2ac..3d647fb 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -1935,6 +1935,9 @@ func (svc *CoreService) GetServerConfigForSession(sessionID string, serverInstan } func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string, serverInstanceID string, fileKey string) (domain.DeclaredFileReadSnapshot, error) { + if err := validator.ValidateServerFileReadSnapshotRequest(serverInstanceID, fileKey); err != nil { + return domain.DeclaredFileReadSnapshot{}, err + } instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID) if err != nil { return domain.DeclaredFileReadSnapshot{}, err @@ -1946,10 +1949,6 @@ func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string, if plugin.Status != domain.GamePluginStatusInstalled || (!plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read")) { return domain.DeclaredFileReadSnapshot{}, ErrForbidden } - file, constrained, allowed := declaredPluginFileRequest(plugin.FileWorkspace, domain.FileOperationDispatchRequest{Operation: domain.FileOperationRead, Key: fileKey}) - if !constrained || !allowed || file.Key == "" { - return domain.DeclaredFileReadSnapshot{}, validationError("file key must reference a plugin-declared file") - } jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID}) if err != nil { return domain.DeclaredFileReadSnapshot{}, err @@ -1958,7 +1957,7 @@ func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string, var pending *domain.Job for i := range jobs { job := jobs[i] - if job.Capability != domain.JobCapabilityFilesRead || job.TargetKey != file.Key { + if job.Capability != domain.JobCapabilityFilesRead || job.TargetKey != fileKey { continue } if job.State == domain.JobStateSucceeded && job.ExecutionResult.Kind == "file.read" { @@ -1973,7 +1972,7 @@ func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string, pending = © } } - base := domain.DeclaredFileReadSnapshot{ServerInstanceID: instance.ID, PluginID: plugin.ID, Key: file.Key} + base := domain.DeclaredFileReadSnapshot{ServerInstanceID: instance.ID, PluginID: plugin.ID, Key: fileKey} if completed != nil { return domain.DeclaredFileReadSnapshot{ ServerInstanceID: base.ServerInstanceID, @@ -1995,7 +1994,7 @@ func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string, return base, nil } base.State = "not-read" - base.Reason = "尚未读取此声明文件。" + base.Reason = "尚未读取此文件。" return base, nil } @@ -2182,12 +2181,6 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques if request.Operation == domain.FileOperationWrite && !containsString(plugin.DeclaredPermissions, "server.files.write") { return domain.FileOperationDispatchResult{}, ErrForbidden } - if file, constrained, allowed := declaredPluginFileRequest(plugin.FileWorkspace, request); constrained && !allowed { - if file.Key == "" { - return domain.FileOperationDispatchResult{}, validationError("file key must reference a plugin-declared file") - } - return domain.FileOperationDispatchResult{}, validationError("file key is not writable by plugin declaration") - } } capability := domain.JobCapabilityFilesRead message := "file read queued" diff --git a/platform/service/resources_test.go b/platform/service/resources_test.go index 29dbac8..1e307e6 100644 --- a/platform/service/resources_test.go +++ b/platform/service/resources_test.go @@ -907,7 +907,7 @@ func TestCoreServiceConfigWriteAndFileDispatchAreScoped(t *testing.T) { } } -func TestDeclaredPluginFileWorkspaceConstrainsFileDispatch(t *testing.T) { +func TestPluginFileWorkspaceDoesNotConstrainServerFileDispatch(t *testing.T) { svc := newTestCoreService() plugin, endpoint := createPluginAndRunEndpoint(t, svc) plugin.FileWorkspace = scumTestFileWorkspace() @@ -946,16 +946,17 @@ func TestDeclaredPluginFileWorkspaceConstrainsFileDispatch(t *testing.T) { if allowed.Job.TargetKey != "scum-server-settings" || allowed.Job.Capability != domain.JobCapabilityFilesRead { t.Fatalf("unexpected declared file dispatch: %+v", allowed) } - if _, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{ + unknown, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{ ServerInstanceID: instance.ID, PluginID: plugin.ID, Operation: domain.FileOperationRead, Key: "logs/latest.log", IdempotencyKey: "idem-file-workspace-unknown", - }); err == nil || !strings.Contains(err.Error(), "plugin-declared file") { - t.Fatalf("expected undeclared file key rejection, got %v", err) + }) + if err != nil || unknown.Job.TargetKey != "logs/latest.log" || unknown.Job.Capability != domain.JobCapabilityFilesRead { + t.Fatalf("expected undeclared file read to queue, dispatch=%+v err=%v", unknown, err) } - if _, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{ + written, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{ ServerInstanceID: instance.ID, PluginID: plugin.ID, Operation: domain.FileOperationWrite, @@ -963,8 +964,9 @@ func TestDeclaredPluginFileWorkspaceConstrainsFileDispatch(t *testing.T) { InputRef: "input://file-workspace/update", Content: "line", IdempotencyKey: "idem-file-workspace-log-write", - }); err == nil || !strings.Contains(err.Error(), "not writable") { - t.Fatalf("expected log write rejection, got %v", err) + }) + if err != nil || written.Job.TargetKey != "scum-chat-log" || written.Job.Capability != domain.JobCapabilityFilesWrite { + t.Fatalf("expected declared log write to queue, dispatch=%+v err=%v", written, err) } } @@ -1013,8 +1015,9 @@ func TestDeclaredFileReadSnapshotProjectionStatesAndRedaction(t *testing.T) { if err != nil || snapshot.JobID != "job-file-snapshot-success-new" || !strings.Contains(snapshot.Content, "ServerName=New") || strings.Contains(snapshot.Content, "secret") { t.Fatalf("expected newest successful redacted result, snapshot=%+v err=%v", snapshot, err) } - if _, err := svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "logs/latest.log"); err == nil || !strings.Contains(err.Error(), "plugin-declared file") { - t.Fatalf("expected unknown logical key rejection, got %v", err) + unknownSnapshot, err := svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "logs/latest.log") + if err != nil || unknownSnapshot.State != "not-read" { + t.Fatalf("expected unknown logical key to report not-read, snapshot=%+v err=%v", unknownSnapshot, err) } if _, err := svc.GetDeclaredFileReadSnapshotForSession(otherSession, instance.ID, "scum-server-settings"); !errors.Is(err, ErrForbidden) { t.Fatalf("expected unrelated session forbidden, got %v", err) diff --git a/platform/service/server_files.go b/platform/service/server_files.go index 30fa3bc..73b1edc 100644 --- a/platform/service/server_files.go +++ b/platform/service/server_files.go @@ -16,10 +16,9 @@ import ( ) const ( - serverFileTransferChannel = "run-file-transfer" - serverFileMaxInlineEditBytes = 64 * 1024 - serverFileDefaultDirectoryKey = "server-files" - serverFileDefaultLogDirectoryKey = "server-logs" + serverFileTransferChannel = "run-file-transfer" + serverFileMaxInlineEditBytes = 64 * 1024 + serverFileDefaultDirectoryKey = "server-root" ) type serverFileContext struct { @@ -65,7 +64,7 @@ func (svc *CoreService) GetServerFileWorkspaceForSession(sessionID string, serve Directories: workspace.Directories, Files: workspace.Files, ConfigFields: workspace.ConfigFields, - DeclaredOnly: serverFileWorkspaceIsPluginDeclared(ctx.Plugin.FileWorkspace), + DeclaredOnly: false, RuntimeWorkspaceScope: svc.runtimeProfileScope(ctx.Instance.ID), Transfer: domain.ServerFileTransferPolicy{ Channel: serverFileTransferChannel, @@ -102,18 +101,14 @@ func (svc *CoreService) ListServerFilesForSession(sessionID string, request doma } } state := "declared" - reason := "展示插件声明的逻辑文件;点击刷新可请求 Run 返回实时目录。" + reason := "展示服务器文件缓存;点击刷新目录读取 Run 实时目录。" if hasLatest && !isTerminalJobState(latest.State) { state = "pending" reason = "Run 正在刷新目录。" } entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Workspace, request.DirectoryKey), request.Query) if state == "declared" { - reasonPrefix := "展示插件声明的逻辑文件" - if !serverFileWorkspaceIsPluginDeclared(ctx.Plugin.FileWorkspace) { - reasonPrefix = "展示通用服务器文件目录" - } - reason = reasonPrefix + ";点击刷新可请求 Run 返回实时目录。" + reason = "展示服务器文件缓存;点击刷新目录读取 Run 实时目录。" } 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 } @@ -179,9 +174,6 @@ func (svc *CoreService) UploadServerFileForSession(sessionID string, request dom 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()} @@ -348,19 +340,8 @@ func (svc *CoreService) serverFileContextForSession(sessionID string, serverInst return serverFileContext{User: user, Instance: instance, Plugin: plugin, Directory: directory, Workspace: workspace, Scope: svc.runtimeProfileScope(instance.ID)}, nil } -func effectiveServerFileWorkspace(plugin domain.GamePlugin) domain.PluginFileWorkspace { - workspace := domain.CopyPluginFileWorkspace(plugin.FileWorkspace) - if len(workspace.Directories) == 0 && workspace.DefaultDirectoryKey == "" { - workspace.DefaultDirectoryKey = serverFileDefaultDirectoryKey - workspace.Directories = []domain.PluginLogicalDirectory{{Key: serverFileDefaultDirectoryKey, Label: "服务器文件", Scope: "config"}} - if plugin.Permissions.Logs || containsString(plugin.DeclaredPermissions, "server.logs.read") || len(plugin.RuntimeProfiles.LogSources) > 0 { - workspace.Directories = append(workspace.Directories, domain.PluginLogicalDirectory{Key: serverFileDefaultLogDirectoryKey, Label: "日志文件", Scope: "logs"}) - } - } - if workspace.DefaultDirectoryKey == "" && len(workspace.Directories) > 0 { - workspace.DefaultDirectoryKey = workspace.Directories[0].Key - } - return workspace +func effectiveServerFileWorkspace(_ domain.GamePlugin) domain.PluginFileWorkspace { + return domain.PluginFileWorkspace{DefaultDirectoryKey: serverFileDefaultDirectoryKey, Directories: []domain.PluginLogicalDirectory{{Key: serverFileDefaultDirectoryKey, Label: "服务器根目录", Scope: "config"}}, Files: []domain.PluginLogicalFile{}, ConfigFields: []domain.PluginConfigField{}} } func serverFileWorkspaceIsPluginDeclared(workspace domain.PluginFileWorkspace) bool { @@ -380,13 +361,13 @@ func serverFileEntriesFromDeclaredWorkspace(workspace domain.PluginFileWorkspace 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: "插件声明目录"}) + entries = append(entries, domain.ServerFileEntry{Name: directory.Label, Kind: domain.ServerFileEntryDirectory, DirectoryKey: directory.Key, RelativePath: "", Scope: directory.Scope, Editable: true, Downloadable: true, 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)}) + entries = append(entries, domain.ServerFileEntry{Name: file.Label, Kind: domain.ServerFileEntryFile, DirectoryKey: file.DirectoryKey, RelativePath: file.Key, LogicalKey: file.Key, Scope: file.Kind, Editable: true, Downloadable: true, Remark: fileRemark(file)}) } sort.SliceStable(entries, func(i int, j int) bool { if entries[i].Kind == entries[j].Kind { @@ -417,10 +398,14 @@ func serverFileEntriesFromRunList(content string, fallbackDirectoryKey string, f directoryKey = fallbackDirectoryKey } relativePath := cleanServerFileRelativePath(entry.RelativePath) - if relativePath == "" { + if relativePath == "" && entry.Name != "" { + relativePath = cleanServerFileRelativePath(path.Join(fallbackPath, entry.Name)) + } else 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}) + editable := kind == domain.ServerFileEntryFile + downloadable := kind == domain.ServerFileEntryFile + 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: editable, Downloadable: downloadable, Remark: entry.Remark}) } return entries, nil } @@ -518,7 +503,7 @@ func fileRemark(file domain.PluginLogicalFile) string { if file.Editable { return "可编辑配置" } - return "插件声明文件" + return "文件" } func serverFileDisplayName(workspace domain.PluginFileWorkspace, key string) string { diff --git a/platform/validator/resources.go b/platform/validator/resources.go index 093545a..fd2e425 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -7,6 +7,7 @@ import ( "regexp" "strconv" "strings" + "unicode" "unicode/utf8" "browser.local/platform/domain" @@ -24,7 +25,7 @@ const ( maxProgressMessageLength = 256 maxServerConfigContentSize = 64 * 1024 maxJobExecutionContentSize = 64 * 1024 - maxLogicalFileKeyLength = 160 + maxLogicalFileKeyLength = 1024 maxProductionMessageLength = 320 ) @@ -1444,6 +1445,16 @@ 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 ValidateServerFileReadSnapshotRequest(serverInstanceID string, key string) error { + var violations []string + violations = appendRequired(violations, "serverInstanceId", serverInstanceID) + violations = appendRequired(violations, "key", key) + if !validLogicalFileKey(key) { + violations = append(violations, "key is not allowed") + } + return finish(violations) +} + 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}) } @@ -2412,14 +2423,13 @@ func validFileOperationKind(operation domain.FileOperationKind) bool { 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) { + if trimmed == "" || trimmed != name || name == "." || name == ".." || len([]rune(name)) > 255 || !utf8.ValidString(name) || strings.Contains(name, "/") || strings.Contains(name, `\`) || strings.Contains(name, ":") || strings.Contains(name, "://") || looksLikeRawHostPath(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 + if unicode.IsControl(char) || char == 0 { + return false } - return false } return true } @@ -2438,14 +2448,18 @@ func validLogicalFileKey(key string) bool { if trimmed == "" || trimmed != key || len([]rune(key)) > maxLogicalFileKeyLength { return false } - if strings.HasPrefix(key, "/") || strings.Contains(key, "..") || strings.Contains(key, `\`) || strings.Contains(key, "://") || looksLikeRawHostPath(key) || containsUnsafeRuntimeSecret(key) { + if !utf8.ValidString(key) || strings.HasPrefix(key, "/") || strings.Contains(key, `\`) || strings.Contains(key, ":") || strings.Contains(key, "://") || looksLikeRawHostPath(key) { return false } + for _, segment := range strings.Split(key, "/") { + if segment == "" || segment == "." || segment == ".." { + return false + } + } for _, char := range key { - if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '.' || char == '/' { - continue + if unicode.IsControl(char) || char == 0 { + return false } - return false } return true } diff --git a/platform_web/api/contracts.md b/platform_web/api/contracts.md index 197fd79..d4607d4 100644 --- a/platform_web/api/contracts.md +++ b/platform_web/api/contracts.md @@ -27,8 +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 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. +- `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 file work. +- `getServerFileWorkspace`, `listServerFiles`, `refreshServerFiles`, `readServerFile`, `getServerFileReadSnapshot`, `writeServerFile`, `uploadServerFile`, and `prepareServerFileDownload` power the first-party server-detail file manager. The page renders a generic server-root entry, requests live listings through `files.list`, reads 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/pages/ServerDetailPage.test.tsx b/platform_web/pages/ServerDetailPage.test.tsx index ec472d5..767c3ee 100644 --- a/platform_web/pages/ServerDetailPage.test.tsx +++ b/platform_web/pages/ServerDetailPage.test.tsx @@ -85,6 +85,16 @@ describe("ServerDetailPage config write approval", () => { expect(serverDetailPageSource).not.toContain("Bearer "); }); + it("keeps the server file manager list-first without plugin declaration gates", () => { + expect(serverDetailPageSource).toContain("server-file-editor-overlay"); + expect(serverDetailPageSource).not.toContain("server-file-layout"); + expect(serverDetailPageSource).not.toContain("未声明目录"); + expect(serverDetailPageSource).not.toContain("插件尚未声明"); + expect(serverDetailPageSource).not.toContain("插件声明为可编辑"); + expect(serverDetailPageSource).not.toContain("entry.editable"); + expect(serverDetailPageSource).not.toContain("entry.downloadable"); + }); + it("keeps run distribution and client-manager workflows out of server detail tabs", () => { expect(serverDetailPageSource).not.toContain('id="run-builder"'); expect(serverDetailPageSource).not.toContain("getServerRuntimeActions"); diff --git a/platform_web/pages/ServerDetailPage.tsx b/platform_web/pages/ServerDetailPage.tsx index cea4f73..2df3ea1 100644 --- a/platform_web/pages/ServerDetailPage.tsx +++ b/platform_web/pages/ServerDetailPage.tsx @@ -1,4 +1,4 @@ -import { ChevronRight, Download, Eye, FileText, Folder, MoonStar, PackageOpen, Pencil, RefreshCw, Save, Search, ShieldCheck, Sparkles, Square, Terminal, Upload, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react"; +import { ChevronRight, Download, Eye, FileText, Folder, MoonStar, PackageOpen, Pencil, RefreshCw, Save, Search, ShieldCheck, Sparkles, Square, Terminal, Upload, UserRoundMinus, UserRoundPlus, WandSparkles, X } from "lucide-react"; import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useState } from "react"; import { platformApiClient } from "../api/client"; @@ -571,7 +571,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio 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" && Boolean(activeDirectory) && activeDirectory?.scope !== "logs" && !uploadBusy; + const canUpload = workspace.status === "ready" && Boolean(activeDirectory) && !uploadBusy; const entries = list.status === "ready" ? list.data.entries : []; const loadWorkspace = useCallback(async () => { @@ -581,9 +581,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio setWorkspace({ status: "ready", data: response }); const nextDirectoryKey = response.defaultDirectoryKey || response.directories[0]?.key || ""; setDirectoryKey((current) => current || nextDirectoryKey); - if (!nextDirectoryKey) { - setList({ status: "ready", data: { serverInstanceId: response.serverInstanceId, pluginId: response.pluginId, directoryKey: "", state: "declared", entries: [], reason: "插件尚未声明文件工作区;需要在插件 manifest 中添加 fileWorkspace。" } }); - } + if (!nextDirectoryKey) setList({ status: "ready", data: { serverInstanceId: response.serverInstanceId, pluginId: response.pluginId, directoryKey: "", state: "declared", entries: [], reason: "尚未获得服务器文件入口。" } }); } catch (error) { setWorkspace({ status: "error", reason: error instanceof Error ? error.message : "文件工作区加载失败" }); setList({ status: "error", reason: "文件工作区不可用" }); @@ -639,7 +637,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio async function openEditor(entry: ServerFileEntryResponse) { const key = serverFileEntryKey(entry); if (!key) { - setPanelResult({ status: "failed", label: "该文件缺少插件声明的逻辑 key,不能读取。" }); + setPanelResult({ status: "failed", label: "该文件缺少路径 key,不能读取。" }); return; } setEditor({ entry, key, draft: "", loading: true, saving: false, message: "正在读取最近快照…" }); @@ -662,7 +660,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio } async function saveEditor() { - if (!editor.entry || !editor.key || editor.saving || !editor.entry.editable) return; + if (!editor.entry || !editor.key || editor.saving) return; const operationId = operations.begin({ intent: "保存文件", targetKind: "server", targetId: instance.id, requester: session.displayName }); setEditor((current) => ({ ...current, saving: true, error: undefined, message: "正在派发写入任务…" })); try { @@ -681,7 +679,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio async function downloadEntry(entry: ServerFileEntryResponse) { const key = serverFileEntryKey(entry); - if (!key || !entry.downloadable) return; + if (!key) return; const operationId = operations.begin({ intent: "下载文件", targetKind: "server", targetId: instance.id, requester: session.displayName }); setPanelResult({ status: "pending", label: "正在准备文件下载…" }); try { @@ -736,6 +734,10 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio } } + function closeEditor() { + setEditor({ entry: null, key: "", draft: "", loading: false, saving: false }); + } + if (workspace.status === "loading") return ; if (workspace.status === "error") return void loadWorkspace()} compact />; @@ -747,18 +749,17 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
- {activeDirectory?.label ?? (directoryKey || "未声明目录")} + {activeDirectory?.label ?? (directoryKey || "服务器根目录")} {relativePath.split("/").filter(Boolean).map((part) => {part})}
-
-
- {workspace.data.directories.length === 0 && 插件尚未声明可浏览目录} +
1 && "server-file-toolbar-has-tabs")}> + {workspace.data.directories.length > 1 &&
{workspace.data.directories.map((directory) => ( ))} -
+
}
setSearchDraft(event.target.value)} /> @@ -767,7 +768,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
-
@@ -776,38 +777,37 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio {list.status === "loading" && } {list.status === "error" && void loadList()} compact />} {list.status === "ready" && ( -
-
+
- + - {entries.length === 0 && } + {entries.length === 0 && } {entries.map((entry) => ( - - + ))}
文件名称大小修改时间备注操作
文件名称大小修改时间备注操作
当前目录没有可展示文件;可刷新实时目录或换一个插件声明目录。
当前目录暂无缓存结果;点击刷新目录读取实时文件。
- {entry.logicalKey || entry.relativePath || entry.directoryKey} {entry.kind === "directory" ? "计算" : formatBytes(entry.sizeBytes)}{entry.kind === "directory" ? "--" : formatBytes(entry.sizeBytes)} {formatDateTime(entry.modifiedAt)} {entry.remark || entry.scope || "--"}
- {entry.kind === "directory" ? : } - {entry.kind === "file" && } + {entry.kind === "directory" ? : } + {entry.kind === "file" && }
-
-
+ )} + {editor.entry && ( +
+
+

文件编辑

{editor.entry.name}
{editor.entry && editor.loading && } {editor.entry && editor.error && } {editor.entry && editor.message && !editor.error && {editor.message}} @@ -819,11 +819,11 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio )} {editor.entry && (
- - + +
)} - +
)} diff --git a/platform_web/theme/base.css b/platform_web/theme/base.css index c9eb149..138eb32 100644 --- a/platform_web/theme/base.css +++ b/platform_web/theme/base.css @@ -455,7 +455,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))} .server-file-back-icon{transform:rotate(180deg)} .server-file-path-chip{display:inline-flex;align-items:center;gap:4px;min-height:30px;padding:0 10px;border:1px solid var(--line);border-radius:999px;background:var(--control-surface);color:var(--ink-soft);font-size:12px;font-weight:800;white-space:nowrap} .server-file-path-child{color:var(--ink-faint)} -.server-file-toolbar{display:grid;grid-template-columns:minmax(180px,1fr) minmax(260px,1.2fr) auto;gap:8px;align-items:center;min-width:0} +.server-file-toolbar{display:grid;grid-template-columns:minmax(320px,1fr) auto;gap:8px;align-items:center;min-width:0}.server-file-toolbar-has-tabs{grid-template-columns:minmax(160px,.8fr) minmax(320px,1.2fr) auto} .server-file-directory-tabs{display:flex;gap:6px;overflow:auto;min-width:0;padding-bottom:2px} .server-file-search{display:flex;align-items:center;gap:7px;min-width:0;min-height:38px;padding:0 8px;border:1px solid var(--line);border-radius:8px;background:var(--control-surface);box-shadow:inset 0 1px 0 var(--crystal-rim)} .server-file-search input[type=search]{min-width:120px;flex:1 1 auto;border:0;background:transparent;color:var(--ink);font:inherit;outline:0} @@ -464,11 +464,11 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))} .server-file-upload-control{min-height:36px;display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid var(--line-strong);border-radius:8px;padding:0 12px;background:var(--control-surface);color:var(--ink-soft);cursor:pointer;box-shadow:inset 0 1px 0 var(--crystal-rim),0 8px 18px var(--glass-shadow);font-weight:700;white-space:nowrap} .server-file-upload-control input{display:none}.server-file-upload-control:hover,.server-file-upload-control:focus-within{border-color:var(--accent);color:var(--ink);box-shadow:inset 0 1px 0 var(--crystal-rim),0 0 0 2px var(--accent-soft),0 12px 26px var(--glass-shadow)} .server-file-upload-disabled{opacity:.55;cursor:not-allowed} -.server-file-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(280px,360px);gap:12px;align-items:start;min-width:0} -.server-file-table-wrap{max-height:560px}.server-file-table{min-width:860px}.server-file-table td:first-child,.server-file-table th:first-child{width:44px}.server-file-directory-row{background:color-mix(in srgb,var(--accent-soft) 54%,transparent)} +.server-file-table-wrap{max-height:min(68vh,760px)}.server-file-table{min-width:760px}.server-file-table th:last-child,.server-file-table td:last-child{width:190px}.server-file-directory-row{background:color-mix(in srgb,var(--accent-soft) 54%,transparent)} .server-file-name-button{display:inline-flex;align-items:center;gap:8px;color:var(--ink);font-weight:850}.server-file-name-button svg{color:var(--accent-deep)} -.server-file-editor{display:grid;gap:10px;padding:12px;border:1px solid var(--line);border-radius:8px;background:var(--glass-wash),var(--glass-tint),var(--surface);box-shadow:inset 0 1px 0 var(--crystal-rim),0 14px 30px var(--glass-shadow);min-width:0} -.server-file-editor-field{display:grid;gap:6px;color:var(--ink-soft);font-size:13px;font-weight:800}.server-file-editor-field textarea{min-height:320px;width:100%;border:1px solid var(--line-strong);border-radius:8px;padding:10px;background:var(--surface-solid);color:var(--ink);font:13px/1.45 var(--font-mono);resize:vertical}.server-file-editor-field textarea:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)} +.server-file-editor-overlay{position:fixed;inset:0;z-index:70;display:grid;place-items:center;padding:24px;background:rgba(0,0,0,.42);backdrop-filter:blur(10px)} +.server-file-editor{display:grid;gap:10px;width:min(960px,calc(100vw - 48px));max-height:calc(100dvh - 48px);overflow:auto;padding:12px;border:1px solid var(--line);border-radius:8px;background:var(--glass-wash),var(--glass-tint),var(--surface);box-shadow:inset 0 1px 0 var(--crystal-rim),0 24px 70px var(--glass-shadow);min-width:0} +.server-file-editor-field{display:grid;gap:6px;color:var(--ink-soft);font-size:13px;font-weight:800}.server-file-editor-field textarea{min-height:320px;height:min(62vh,560px);width:100%;border:1px solid var(--line-strong);border-radius:8px;padding:10px;background:var(--surface-solid);color:var(--ink);font:13px/1.45 var(--font-mono);resize:vertical}.server-file-editor-field textarea:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)} .server-file-editor-actions .primary-command{width:auto} .server-card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr));gap:16px} .server-card{display:grid;gap:12px;padding:16px;border:1px solid var(--line);border-radius:8px;background:var(--frosted-surface),var(--glass-tint),var(--surface);backdrop-filter:blur(22px) saturate(1.28);text-align:left;transition:transform 120ms ease,border-color 120ms ease;box-shadow:var(--jelly-inset),inset 0 0 0 1px var(--diamond-line),0 18px 42px var(--glass-shadow),0 0 28px rgba(255,255,255,.2);position:relative;overflow:hidden;min-width:0} @@ -726,7 +726,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))} .section-tabs{overflow-x:auto;flex-wrap:nowrap;padding-bottom:4px} .server-toolbar{align-items:stretch} .server-toolbar input[type=search],.server-toolbar select{flex:1 1 100%;min-width:0} -.server-file-toolbar,.server-file-layout{grid-template-columns:1fr}.server-file-search,.server-file-actions,.server-file-pathbar{align-items:stretch;flex-wrap:wrap}.server-file-search input[type=search]{min-width:0}.server-file-actions{justify-content:stretch}.server-file-actions>*{flex:1 1 auto} +.server-file-toolbar{grid-template-columns:1fr}.server-file-search,.server-file-actions,.server-file-pathbar{align-items:stretch;flex-wrap:wrap}.server-file-search input[type=search]{min-width:0}.server-file-actions{justify-content:stretch}.server-file-actions>*{flex:1 1 auto} .plugin-control-row,.server-card-head,.server-detail-title-row{grid-template-columns:1fr;align-items:stretch} .server-card-head,.server-detail-title-row{display:grid} .server-detail-title-row .action-strip{align-items:stretch}