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
+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 {