Make server file manager list-first

This commit is contained in:
npc0-hue
2026-08-24 15:39:05 +08:00
parent 49b90ebd29
commit 3005e0510c
10 changed files with 113 additions and 114 deletions
+1 -1
View File
@@ -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"
+6 -12
View File
@@ -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)
}
}
+6 -13
View File
@@ -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 = &copy
}
}
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"
+12 -9
View File
@@ -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)
+17 -32
View File
@@ -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 {
+23 -9
View File
@@ -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
}