Add SCUM file management workbench

This commit is contained in:
npc0-hue
2026-08-04 11:33:34 +08:00
parent 2921edb401
commit f028a343d7
36 changed files with 1384 additions and 158 deletions
+137
View File
@@ -123,6 +123,7 @@ type Core interface {
ListRemoteAdapterDeclarationsForSession(string, string) ([]domain.RemoteAdapterDeclaration, error)
RequestRemoteAdapterForSession(string, domain.RemoteAdapterRequest) (domain.RemoteAdapterResult, error)
GetServerConfigForSession(string, string) (domain.ServerConfig, error)
GetDeclaredFileReadSnapshotForSession(string, string, string) (domain.DeclaredFileReadSnapshot, error)
PreviewServerConfigWriteForSession(string, domain.ServerConfigDiffRequest) (domain.ServerConfigDiffPreview, error)
ApproveServerConfigWriteForSession(string, domain.ServerConfigWriteApproval) (domain.ServerConfigWriteDispatch, error)
DispatchFileOperationForSession(string, domain.FileOperationDispatchRequest) (domain.FileOperationDispatchResult, error)
@@ -1883,6 +1884,120 @@ func (svc *CoreService) GetServerConfigForSession(sessionID string, serverInstan
return domain.CopyServerConfig(config), nil
}
func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string, serverInstanceID string, fileKey string) (domain.DeclaredFileReadSnapshot, error) {
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
if err != nil {
return domain.DeclaredFileReadSnapshot{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.DeclaredFileReadSnapshot{}, err
}
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
}
var completed *domain.Job
var pending *domain.Job
for i := range jobs {
job := jobs[i]
if job.Capability != domain.JobCapabilityFilesRead || job.TargetKey != file.Key {
continue
}
if job.State == domain.JobStateSucceeded && job.ExecutionResult.Kind == "file.read" {
if completed == nil || newerJob(job, *completed) {
copy := job
completed = &copy
}
continue
}
if declaredFileReadPendingState(job.State) && (pending == nil || newerJob(job, *pending)) {
copy := job
pending = &copy
}
}
base := domain.DeclaredFileReadSnapshot{ServerInstanceID: instance.ID, PluginID: plugin.ID, Key: file.Key}
if completed != nil {
return domain.DeclaredFileReadSnapshot{
ServerInstanceID: base.ServerInstanceID,
PluginID: base.PluginID,
Key: base.Key,
State: "ready",
Content: redactDeclaredFileReadContent(completed.ExecutionResult.Content),
Version: completed.ExecutionResult.Version,
Checksum: completed.ExecutionResult.Checksum,
SizeBytes: completed.ExecutionResult.SizeBytes,
JobID: completed.ID,
ReadAt: jobCompletedAt(*completed),
}, nil
}
if pending != nil {
base.State = "pending"
base.JobID = pending.ID
base.Reason = "等待运行端完成文件读取。"
return base, nil
}
base.State = "not-read"
base.Reason = "尚未读取此声明文件。"
return base, nil
}
func declaredFileReadPendingState(state domain.JobState) bool {
switch state {
case domain.JobStateQueued, domain.JobStateAccepted, domain.JobStateRunning, domain.JobStateRetrying:
return true
default:
return false
}
}
func newerJob(left domain.Job, right domain.Job) bool {
leftTime, rightTime := jobCompletedAt(left), jobCompletedAt(right)
if !leftTime.Equal(rightTime) {
return leftTime.After(rightTime)
}
return left.ID > right.ID
}
func jobCompletedAt(job domain.Job) time.Time {
if !job.TerminalAt.IsZero() {
return job.TerminalAt
}
if !job.UpdatedAt.IsZero() {
return job.UpdatedAt
}
return job.CreatedAt
}
func redactDeclaredFileReadContent(content string) string {
lines := strings.Split(content, "\n")
for index, line := range lines {
key, _, found := strings.Cut(line, "=")
if !found || !secretLikeFileAssignmentKey(key) {
continue
}
lines[index] = key + "=<redacted>"
}
return strings.Join(lines, "\n")
}
func secretLikeFileAssignmentKey(key string) bool {
normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(key), "_", ""), "-", ""))
for _, marker := range []string{"password", "passwd", "secret", "token", "apikey", "accesskey", "privatekey", "rcon"} {
if strings.Contains(normalized, marker) {
return true
}
}
return false
}
func (svc *CoreService) PreviewServerConfigWriteForSession(sessionID string, request domain.ServerConfigDiffRequest) (domain.ServerConfigDiffPreview, error) {
if request.Key == "" {
request.Key = "server.properties"
@@ -2022,6 +2137,12 @@ 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"
@@ -2054,6 +2175,22 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
}), nil
}
func declaredPluginFileRequest(workspace domain.PluginFileWorkspace, request domain.FileOperationDispatchRequest) (domain.PluginLogicalFile, bool, bool) {
if len(workspace.Files) == 0 {
return domain.PluginLogicalFile{}, false, true
}
for _, file := range workspace.Files {
if file.Key != request.Key {
continue
}
if request.Operation == domain.FileOperationWrite && (file.Kind != "config" || !file.Editable) {
return file, true, false
}
return file, true, true
}
return domain.PluginLogicalFile{}, true, false
}
func (svc *CoreService) runtimeProfileScope(serverInstanceID string) string {
binding, err := svc.runtimeBindingForServer(serverInstanceID)
if err != nil {