Add SCUM file management workbench
This commit is contained in:
@@ -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 = ©
|
||||
}
|
||||
continue
|
||||
}
|
||||
if declaredFileReadPendingState(job.State) && (pending == nil || newerJob(job, *pending)) {
|
||||
copy := job
|
||||
pending = ©
|
||||
}
|
||||
}
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user