Add server file manager workflow
This commit is contained in:
@@ -311,6 +311,10 @@ func validateExecutionResultForJob(job domain.Job, result domain.RunJobResult) e
|
||||
if result.ExecutionResult.Checksum == "" || result.ExecutionResult.Checksum != validator.BytesChecksum([]byte(job.ExecutionInput.Content)) {
|
||||
return validationError("config write result checksum is invalid")
|
||||
}
|
||||
case domain.JobCapabilityFilesList:
|
||||
if result.ExecutionResult.Kind != "file.list" {
|
||||
return validationError("file list result type is invalid")
|
||||
}
|
||||
case domain.JobCapabilityFilesRead:
|
||||
if result.ExecutionResult.Kind != "file.read" {
|
||||
return validationError("file read result type is invalid")
|
||||
|
||||
@@ -123,6 +123,13 @@ type Core interface {
|
||||
RequestRemoteAdapterForSession(string, domain.RemoteAdapterRequest) (domain.RemoteAdapterResult, error)
|
||||
GetServerConfigForSession(string, string) (domain.ServerConfig, error)
|
||||
GetDeclaredFileReadSnapshotForSession(string, string, string) (domain.DeclaredFileReadSnapshot, error)
|
||||
GetServerFileWorkspaceForSession(string, string) (domain.ServerFileWorkspaceView, error)
|
||||
ListServerFilesForSession(string, domain.ServerFileListRequest) (domain.ServerFileListResult, error)
|
||||
RefreshServerFileListForSession(string, domain.ServerFileListRequest) (domain.ServerFileListResult, error)
|
||||
ReadServerFileForSession(string, domain.ServerFileReadRequest) (domain.FileOperationDispatchResult, error)
|
||||
WriteServerFileForSession(string, domain.ServerFileWriteRequest) (domain.FileOperationDispatchResult, error)
|
||||
UploadServerFileForSession(string, domain.ServerFileUploadRequest) (domain.ServerFileUploadDispatch, error)
|
||||
PrepareServerFileDownloadForSession(string, domain.ServerFileDownloadRequest) (domain.ServerFileDownloadResult, error)
|
||||
PreviewServerConfigWriteForSession(string, domain.ServerConfigDiffRequest) (domain.ServerConfigDiffPreview, error)
|
||||
ApproveServerConfigWriteForSession(string, domain.ServerConfigWriteApproval) (domain.ServerConfigWriteDispatch, error)
|
||||
DispatchFileOperationForSession(string, domain.FileOperationDispatchRequest) (domain.FileOperationDispatchResult, error)
|
||||
@@ -143,6 +150,7 @@ type Core interface {
|
||||
GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest) (domain.SourceRCONExecutionInput, error)
|
||||
GetRunUpdateInput(domain.RunUpdateInputRequest) (domain.RunUpdateInput, error)
|
||||
ReadRunUpdateChunk(domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error)
|
||||
ReadRunFileInputChunk(domain.RunFileInputChunkRequest) (domain.RunFileInputChunk, error)
|
||||
ReportRunUpdateHealth(domain.RunUpdateHealthReport) (domain.RunUpdateHealthResult, error)
|
||||
RequestRunJobCancel(domain.RunJobCancelRequest) (domain.RunJobCancelRequestResult, error)
|
||||
PollRunJobCancel(domain.RunJobCancelPoll) (domain.RunJobCancelPollResult, error)
|
||||
@@ -2156,11 +2164,6 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
|
||||
if artifact.OwnerKind != domain.ArtifactOwnerKindServerInstance || artifact.OwnerID != instance.ID || artifact.State != domain.ArtifactStateAvailable {
|
||||
return domain.FileOperationDispatchResult{}, ErrForbidden
|
||||
}
|
||||
payload, payloadErr := svc.artifactPayload(artifactID)
|
||||
if payloadErr != nil {
|
||||
return domain.FileOperationDispatchResult{}, payloadErr
|
||||
}
|
||||
content = string(payload)
|
||||
}
|
||||
if request.PluginID != "" {
|
||||
plugin, err := svc.store.GamePlugins().Get(request.PluginID)
|
||||
@@ -2173,7 +2176,7 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
|
||||
if plugin.Status != domain.GamePluginStatusInstalled {
|
||||
return domain.FileOperationDispatchResult{}, validationError("plugin must be installed")
|
||||
}
|
||||
if request.Operation == domain.FileOperationRead && !plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read") {
|
||||
if (request.Operation == domain.FileOperationList || request.Operation == domain.FileOperationRead) && !plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read") {
|
||||
return domain.FileOperationDispatchResult{}, ErrForbidden
|
||||
}
|
||||
if request.Operation == domain.FileOperationWrite && !containsString(plugin.DeclaredPermissions, "server.files.write") {
|
||||
@@ -2188,6 +2191,10 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
|
||||
}
|
||||
capability := domain.JobCapabilityFilesRead
|
||||
message := "file read queued"
|
||||
if request.Operation == domain.FileOperationList {
|
||||
capability = domain.JobCapabilityFilesList
|
||||
message = "file list queued"
|
||||
}
|
||||
if request.Operation == domain.FileOperationWrite {
|
||||
capability = domain.JobCapabilityFilesWrite
|
||||
message = "file write queued"
|
||||
@@ -2218,6 +2225,17 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
|
||||
}
|
||||
|
||||
func declaredPluginFileRequest(workspace domain.PluginFileWorkspace, request domain.FileOperationDispatchRequest) (domain.PluginLogicalFile, bool, bool) {
|
||||
if request.Operation == domain.FileOperationList {
|
||||
if len(workspace.Directories) == 0 {
|
||||
return domain.PluginLogicalFile{}, false, true
|
||||
}
|
||||
for _, directory := range workspace.Directories {
|
||||
if directory.Key == request.Key {
|
||||
return domain.PluginLogicalFile{}, true, true
|
||||
}
|
||||
}
|
||||
return domain.PluginLogicalFile{}, true, false
|
||||
}
|
||||
if len(workspace.Files) == 0 {
|
||||
return domain.PluginLogicalFile{}, false, true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/repo"
|
||||
"browser.local/platform/validator"
|
||||
)
|
||||
|
||||
const (
|
||||
serverFileTransferChannel = "run-file-transfer"
|
||||
serverFileMaxInlineEditBytes = 64 * 1024
|
||||
)
|
||||
|
||||
type serverFileContext struct {
|
||||
User domain.User
|
||||
Instance domain.ServerInstance
|
||||
Plugin domain.GamePlugin
|
||||
Directory domain.PluginLogicalDirectory
|
||||
Scope string
|
||||
}
|
||||
|
||||
type runFileListEnvelope struct {
|
||||
DirectoryKey string `json:"directoryKey"`
|
||||
Path string `json:"path"`
|
||||
Entries []runFileListEntry `json:"entries"`
|
||||
}
|
||||
|
||||
type runFileListEntry struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
DirectoryKey string `json:"directoryKey"`
|
||||
RelativePath string `json:"relativePath"`
|
||||
LogicalKey string `json:"logicalKey"`
|
||||
Scope string `json:"scope"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
ModifiedAt string `json:"modifiedAt"`
|
||||
Checksum string `json:"checksum"`
|
||||
Editable bool `json:"editable"`
|
||||
Downloadable bool `json:"downloadable"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
func (svc *CoreService) GetServerFileWorkspaceForSession(sessionID string, serverInstanceID string) (domain.ServerFileWorkspaceView, error) {
|
||||
ctx, err := svc.serverFileContextForSession(sessionID, serverInstanceID, "", false, false)
|
||||
if err != nil {
|
||||
return domain.ServerFileWorkspaceView{}, err
|
||||
}
|
||||
workspace := domain.CopyPluginFileWorkspace(ctx.Plugin.FileWorkspace)
|
||||
if workspace.DefaultDirectoryKey == "" && len(workspace.Directories) > 0 {
|
||||
workspace.DefaultDirectoryKey = workspace.Directories[0].Key
|
||||
}
|
||||
view := domain.ServerFileWorkspaceView{
|
||||
ServerInstanceID: ctx.Instance.ID,
|
||||
PluginID: ctx.Plugin.ID,
|
||||
DefaultDirectoryKey: workspace.DefaultDirectoryKey,
|
||||
Directories: workspace.Directories,
|
||||
Files: workspace.Files,
|
||||
ConfigFields: workspace.ConfigFields,
|
||||
DeclaredOnly: true,
|
||||
RuntimeWorkspaceScope: svc.runtimeProfileScope(ctx.Instance.ID),
|
||||
Transfer: domain.ServerFileTransferPolicy{
|
||||
Channel: serverFileTransferChannel,
|
||||
UploadChunkSizeBytes: validator.MaxArtifactChunkBytes,
|
||||
DownloadChunkSizeBytes: validator.MaxArtifactDownloadBytes,
|
||||
MaxInlineEditBytes: serverFileMaxInlineEditBytes,
|
||||
MaxBrowserUploadBytes: validator.MaxArtifactBytes,
|
||||
Notes: []string{
|
||||
"文件字节通过独立文件传输端点传递,不占用 control、jobs 或 logs 通道。",
|
||||
"Web 只发送逻辑目录和相对路径;Run 在本机工作区内解析真实路径。",
|
||||
},
|
||||
},
|
||||
}
|
||||
return domain.CopyServerFileWorkspaceView(view), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ListServerFilesForSession(sessionID string, request domain.ServerFileListRequest) (domain.ServerFileListResult, error) {
|
||||
request = normalizeServerFileListRequest(request)
|
||||
if err := validator.ValidateServerFileListRequest(request); err != nil {
|
||||
return domain.ServerFileListResult{}, err
|
||||
}
|
||||
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, false, false)
|
||||
if err != nil {
|
||||
return domain.ServerFileListResult{}, err
|
||||
}
|
||||
latest, hasLatest, err := svc.latestFileListJob(ctx.Instance.ID, request.DirectoryKey, request.Path)
|
||||
if err != nil {
|
||||
return domain.ServerFileListResult{}, err
|
||||
}
|
||||
if hasLatest && latest.State == domain.JobStateSucceeded && latest.ExecutionResult.Kind == "file.list" {
|
||||
entries, parseErr := serverFileEntriesFromRunList(latest.ExecutionResult.Content, request.DirectoryKey, request.Path)
|
||||
if parseErr == nil {
|
||||
return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: "ready", Entries: filterServerFileEntries(entries, request.Query), Job: latest, RefreshedAt: latest.TerminalAt}), nil
|
||||
}
|
||||
}
|
||||
state := "declared"
|
||||
reason := "展示插件声明的逻辑文件;点击刷新可请求 Run 返回实时目录。"
|
||||
if hasLatest && !isTerminalJobState(latest.State) {
|
||||
state = "pending"
|
||||
reason = "Run 正在刷新目录。"
|
||||
}
|
||||
entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Plugin.FileWorkspace, request.DirectoryKey), request.Query)
|
||||
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
|
||||
}
|
||||
|
||||
func (svc *CoreService) RefreshServerFileListForSession(sessionID string, request domain.ServerFileListRequest) (domain.ServerFileListResult, error) {
|
||||
request = normalizeServerFileListRequest(request)
|
||||
if request.IdempotencyKey == "" {
|
||||
request.IdempotencyKey = fmt.Sprintf("file-list:%s:%s:%s", request.ServerInstanceID, request.DirectoryKey, request.Path)
|
||||
}
|
||||
if err := validator.ValidateServerFileListRequest(request); err != nil {
|
||||
return domain.ServerFileListResult{}, err
|
||||
}
|
||||
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, false, true)
|
||||
if err != nil {
|
||||
return domain.ServerFileListResult{}, err
|
||||
}
|
||||
job, err := svc.CreateJob(domain.Job{
|
||||
ID: jobIDFromParts("job-file-list", request.ServerInstanceID, request.IdempotencyKey),
|
||||
ServerInstanceID: ctx.Instance.ID,
|
||||
RunEndpointID: ctx.Instance.RunEndpointID,
|
||||
Capability: domain.JobCapabilityFilesList,
|
||||
TargetKey: request.DirectoryKey,
|
||||
InputRef: "",
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(ctx.Instance.ID), Inputs: map[string]string{
|
||||
"directoryKey": request.DirectoryKey,
|
||||
"path": request.Path,
|
||||
"recursive": strconv.FormatBool(request.Recursive),
|
||||
"query": request.Query,
|
||||
}, MaxReadBytes: serverFileMaxInlineEditBytes},
|
||||
IdempotencyKey: request.IdempotencyKey,
|
||||
Progress: domain.JobProgress{Percent: 0, Phase: "queued", Message: "file list queued"},
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ServerFileListResult{}, err
|
||||
}
|
||||
entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Plugin.FileWorkspace, request.DirectoryKey), request.Query)
|
||||
return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: "pending", Entries: entries, Job: job, Reason: "目录刷新任务已派发到 Run。"}), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ReadServerFileForSession(sessionID string, request domain.ServerFileReadRequest) (domain.FileOperationDispatchResult, error) {
|
||||
if err := validator.ValidateServerFileReadRequest(request); err != nil {
|
||||
return domain.FileOperationDispatchResult{}, err
|
||||
}
|
||||
return svc.DispatchFileOperationForSession(sessionID, domain.FileOperationDispatchRequest{ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: domain.FileOperationRead, Key: request.Key, IdempotencyKey: request.IdempotencyKey})
|
||||
}
|
||||
|
||||
func (svc *CoreService) WriteServerFileForSession(sessionID string, request domain.ServerFileWriteRequest) (domain.FileOperationDispatchResult, error) {
|
||||
if request.InputRef == "" {
|
||||
request.InputRef = fileManagerInlineInputRef(request.ServerInstanceID, request.Key, request.IdempotencyKey)
|
||||
}
|
||||
if err := validator.ValidateServerFileWriteRequest(request); err != nil {
|
||||
return domain.FileOperationDispatchResult{}, err
|
||||
}
|
||||
return svc.DispatchFileOperationForSession(sessionID, 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})
|
||||
}
|
||||
|
||||
func (svc *CoreService) UploadServerFileForSession(sessionID string, request domain.ServerFileUploadRequest) (domain.ServerFileUploadDispatch, error) {
|
||||
request = domain.CopyServerFileUploadRequest(request)
|
||||
if err := validator.ValidateServerFileUploadRequest(request); err != nil {
|
||||
return domain.ServerFileUploadDispatch{}, err
|
||||
}
|
||||
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, true, true)
|
||||
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()}
|
||||
if err := svc.putBrowserFileArtifact(artifact, request.Payload); err != nil {
|
||||
return domain.ServerFileUploadDispatch{}, err
|
||||
}
|
||||
inputRef := "artifact://" + artifact.ID
|
||||
targetKey := cleanServerFileRelativePath(path.Join(request.DirectoryKey, relativePath))
|
||||
job, err := svc.CreateJob(domain.Job{
|
||||
ID: jobIDFromParts("job-file-upload", request.ServerInstanceID, request.IdempotencyKey),
|
||||
ServerInstanceID: ctx.Instance.ID,
|
||||
RunEndpointID: ctx.Instance.RunEndpointID,
|
||||
Capability: domain.JobCapabilityFilesWrite,
|
||||
TargetKey: targetKey,
|
||||
InputRef: inputRef,
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(ctx.Instance.ID), ExpectedChecksum: request.Checksum, Inputs: map[string]string{
|
||||
"directoryKey": request.DirectoryKey,
|
||||
"relativePath": relativePath,
|
||||
"filename": request.Filename,
|
||||
"transfer": serverFileTransferChannel,
|
||||
}},
|
||||
IdempotencyKey: request.IdempotencyKey,
|
||||
Progress: domain.JobProgress{Percent: 0, Phase: "queued", Message: "file upload staged; Run will pull input chunks"},
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ServerFileUploadDispatch{}, err
|
||||
}
|
||||
return domain.CopyServerFileUploadDispatch(domain.ServerFileUploadDispatch{Status: "queued", ServerInstanceID: ctx.Instance.ID, DirectoryKey: request.DirectoryKey, RelativePath: relativePath, ArtifactID: artifact.ID, InputRef: inputRef, SizeBytes: artifact.SizeBytes, Checksum: artifact.Checksum, Job: job}), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) PrepareServerFileDownloadForSession(sessionID string, request domain.ServerFileDownloadRequest) (domain.ServerFileDownloadResult, error) {
|
||||
if err := validator.ValidateServerFileDownloadRequest(request); err != nil {
|
||||
return domain.ServerFileDownloadResult{}, err
|
||||
}
|
||||
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, "", false, true)
|
||||
if err != nil {
|
||||
return domain.ServerFileDownloadResult{}, err
|
||||
}
|
||||
filename := serverFileDisplayName(ctx.Plugin.FileWorkspace, request.Key)
|
||||
job, hasJob, err := svc.latestFileReadJob(ctx.Instance.ID, request.Key)
|
||||
if err != nil {
|
||||
return domain.ServerFileDownloadResult{}, err
|
||||
}
|
||||
if hasJob && job.State == domain.JobStateSucceeded && job.ExecutionResult.Kind == "file.read" {
|
||||
if strings.HasPrefix(job.ResultRef, "artifact://") {
|
||||
reference, openErr := svc.OpenArtifactDownloadForSession(sessionID, domain.ArtifactDownloadReferenceRequest{ArtifactID: strings.TrimPrefix(job.ResultRef, "artifact://")})
|
||||
if openErr != nil {
|
||||
return domain.ServerFileDownloadResult{}, openErr
|
||||
}
|
||||
return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "ready", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, ContentType: reference.ContentType, Checksum: reference.Checksum, SizeBytes: reference.SizeBytes, Artifact: &reference, Job: job, ReadAt: job.TerminalAt}), nil
|
||||
}
|
||||
if job.ExecutionResult.Content != "" {
|
||||
content := redactDeclaredFileReadContent(job.ExecutionResult.Content)
|
||||
return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "ready", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, ContentType: "text/plain; charset=utf-8", Content: content, Checksum: job.ExecutionResult.Checksum, SizeBytes: int64(len([]byte(content))), Job: job, ReadAt: job.TerminalAt}), nil
|
||||
}
|
||||
}
|
||||
if hasJob && !isTerminalJobState(job.State) {
|
||||
return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "pending", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, Job: job, Reason: "文件读取任务仍在执行。"}), nil
|
||||
}
|
||||
dispatch, err := svc.ReadServerFileForSession(sessionID, domain.ServerFileReadRequest{ServerInstanceID: request.ServerInstanceID, PluginID: ctx.Plugin.ID, Key: request.Key, IdempotencyKey: request.IdempotencyKey})
|
||||
if err != nil {
|
||||
return domain.ServerFileDownloadResult{}, err
|
||||
}
|
||||
return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "pending", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, Job: dispatch.Job, Reason: "文件读取任务已派发;完成后可再次下载。"}), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ReadRunFileInputChunk(request domain.RunFileInputChunkRequest) (domain.RunFileInputChunk, error) {
|
||||
request = domain.CopyRunFileInputChunkRequest(request)
|
||||
if err := validator.ValidateRunFileInputChunkRequest(request); err != nil {
|
||||
return domain.RunFileInputChunk{}, err
|
||||
}
|
||||
session, err := svc.validatedRunSession(request.RunEndpointID, request.SessionToken)
|
||||
if err != nil {
|
||||
return domain.RunFileInputChunk{}, err
|
||||
}
|
||||
svc.jobMu.Lock()
|
||||
job, err := svc.fencedJob(session, request.JobID, request.LeaseToken, request.Attempt)
|
||||
if err != nil {
|
||||
svc.jobMu.Unlock()
|
||||
return domain.RunFileInputChunk{}, err
|
||||
}
|
||||
if job.Capability != domain.JobCapabilityFilesWrite || !strings.HasPrefix(job.InputRef, "artifact://") {
|
||||
svc.jobMu.Unlock()
|
||||
return domain.RunFileInputChunk{}, validationError("job does not reference a file input artifact")
|
||||
}
|
||||
if job.State != domain.JobStateAccepted && job.State != domain.JobStateRunning {
|
||||
svc.jobMu.Unlock()
|
||||
return domain.RunFileInputChunk{}, validationError("job is not active")
|
||||
}
|
||||
artifactID := strings.TrimPrefix(job.InputRef, "artifact://")
|
||||
serverInstanceID := job.ServerInstanceID
|
||||
svc.jobMu.Unlock()
|
||||
|
||||
artifact, err := svc.store.Artifacts().Get(artifactID)
|
||||
if err != nil {
|
||||
return domain.RunFileInputChunk{}, err
|
||||
}
|
||||
if artifact.OwnerKind != domain.ArtifactOwnerKindServerInstance || artifact.OwnerID != serverInstanceID || artifact.State != domain.ArtifactStateAvailable {
|
||||
return domain.RunFileInputChunk{}, ErrForbidden
|
||||
}
|
||||
payload, err := svc.artifactPayload(artifact.ID)
|
||||
if err != nil {
|
||||
return domain.RunFileInputChunk{}, err
|
||||
}
|
||||
if int64(len(payload)) != artifact.SizeBytes || validator.BytesChecksum(payload) != artifact.Checksum {
|
||||
return domain.RunFileInputChunk{}, validationError("file input artifact checksum mismatch")
|
||||
}
|
||||
if request.Offset >= artifact.SizeBytes {
|
||||
return domain.RunFileInputChunk{}, validationError("offset must be inside artifact content")
|
||||
}
|
||||
length := request.Length
|
||||
remaining := artifact.SizeBytes - request.Offset
|
||||
if int64(length) > remaining {
|
||||
length = int(remaining)
|
||||
}
|
||||
end := int(request.Offset) + length
|
||||
chunk := domain.RunFileInputChunk{JobID: job.ID, ArtifactID: artifact.ID, Offset: request.Offset, TotalBytes: artifact.SizeBytes, Checksum: artifact.Checksum, Payload: payload[int(request.Offset):end], Complete: int64(end) == artifact.SizeBytes}
|
||||
return domain.CopyRunFileInputChunk(chunk), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) serverFileContextForSession(sessionID string, serverInstanceID string, directoryKey string, requireWrite bool, requireRuntime bool) (serverFileContext, error) {
|
||||
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
|
||||
if err != nil {
|
||||
return serverFileContext{}, err
|
||||
}
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return serverFileContext{}, err
|
||||
}
|
||||
if requireRuntime {
|
||||
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "file.manager.denied"); err != nil {
|
||||
return serverFileContext{}, err
|
||||
}
|
||||
}
|
||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||
if err != nil {
|
||||
return serverFileContext{}, err
|
||||
}
|
||||
if plugin.Status != domain.GamePluginStatusInstalled {
|
||||
return serverFileContext{}, validationError("plugin must be installed")
|
||||
}
|
||||
if requireWrite {
|
||||
if !containsString(plugin.DeclaredPermissions, "server.files.write") {
|
||||
return serverFileContext{}, ErrForbidden
|
||||
}
|
||||
} else if !plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read") {
|
||||
return serverFileContext{}, ErrForbidden
|
||||
}
|
||||
directory := domain.PluginLogicalDirectory{}
|
||||
if directoryKey != "" {
|
||||
var found bool
|
||||
for _, candidate := range plugin.FileWorkspace.Directories {
|
||||
if candidate.Key == directoryKey {
|
||||
directory = candidate
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && len(plugin.FileWorkspace.Directories) > 0 {
|
||||
return serverFileContext{}, validationError("directoryKey must reference a plugin-declared directory")
|
||||
}
|
||||
}
|
||||
return serverFileContext{User: user, Instance: instance, Plugin: plugin, Directory: directory, Scope: svc.runtimeProfileScope(instance.ID)}, nil
|
||||
}
|
||||
|
||||
func normalizeServerFileListRequest(request domain.ServerFileListRequest) domain.ServerFileListRequest {
|
||||
request.DirectoryKey = strings.TrimSpace(request.DirectoryKey)
|
||||
request.Path = cleanServerFileRelativePath(request.Path)
|
||||
request.Query = strings.TrimSpace(request.Query)
|
||||
return request
|
||||
}
|
||||
|
||||
func serverFileEntriesFromDeclaredWorkspace(workspace domain.PluginFileWorkspace, directoryKey string) []domain.ServerFileEntry {
|
||||
entries := make([]domain.ServerFileEntry, 0)
|
||||
for _, directory := range workspace.Directories {
|
||||
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: "插件声明目录"})
|
||||
}
|
||||
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)})
|
||||
}
|
||||
sort.SliceStable(entries, func(i int, j int) bool {
|
||||
if entries[i].Kind == entries[j].Kind {
|
||||
return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)
|
||||
}
|
||||
return entries[i].Kind == domain.ServerFileEntryDirectory
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
func serverFileEntriesFromRunList(content string, fallbackDirectoryKey string, fallbackPath string) ([]domain.ServerFileEntry, error) {
|
||||
var envelope runFileListEnvelope
|
||||
if err := json.Unmarshal([]byte(content), &envelope); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := make([]domain.ServerFileEntry, 0, len(envelope.Entries))
|
||||
for _, entry := range envelope.Entries {
|
||||
kind := domain.ServerFileEntryFile
|
||||
if entry.Kind == string(domain.ServerFileEntryDirectory) {
|
||||
kind = domain.ServerFileEntryDirectory
|
||||
}
|
||||
modifiedAt := time.Time{}
|
||||
if entry.ModifiedAt != "" {
|
||||
modifiedAt, _ = time.Parse(time.RFC3339, entry.ModifiedAt)
|
||||
}
|
||||
directoryKey := entry.DirectoryKey
|
||||
if directoryKey == "" {
|
||||
directoryKey = fallbackDirectoryKey
|
||||
}
|
||||
relativePath := cleanServerFileRelativePath(entry.RelativePath)
|
||||
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})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func filterServerFileEntries(entries []domain.ServerFileEntry, query string) []domain.ServerFileEntry {
|
||||
query = strings.ToLower(strings.TrimSpace(query))
|
||||
if query == "" {
|
||||
return domain.CopyServerFileEntries(entries)
|
||||
}
|
||||
filtered := make([]domain.ServerFileEntry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if strings.Contains(strings.ToLower(entry.Name+" "+entry.RelativePath+" "+entry.LogicalKey+" "+entry.Remark), query) {
|
||||
filtered = append(filtered, entry)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (svc *CoreService) latestFileListJob(serverInstanceID string, directoryKey string, relativePath string) (domain.Job, bool, error) {
|
||||
return svc.latestServerFileJob(serverInstanceID, domain.JobCapabilityFilesList, directoryKey)
|
||||
}
|
||||
|
||||
func (svc *CoreService) latestFileReadJob(serverInstanceID string, key string) (domain.Job, bool, error) {
|
||||
return svc.latestServerFileJob(serverInstanceID, domain.JobCapabilityFilesRead, key)
|
||||
}
|
||||
|
||||
func (svc *CoreService) latestServerFileJob(serverInstanceID string, capability string, targetKey string) (domain.Job, bool, error) {
|
||||
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: serverInstanceID})
|
||||
if err != nil {
|
||||
return domain.Job{}, false, err
|
||||
}
|
||||
var latest domain.Job
|
||||
found := false
|
||||
for _, job := range jobs {
|
||||
if job.Capability != capability || job.TargetKey != targetKey {
|
||||
continue
|
||||
}
|
||||
if !found || job.UpdatedAt.After(latest.UpdatedAt) || job.CreatedAt.After(latest.CreatedAt) {
|
||||
latest = job
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return domain.CopyJob(latest), found, nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) putBrowserFileArtifact(artifact domain.Artifact, payload []byte) error {
|
||||
svc.artifactMu.Lock()
|
||||
defer svc.artifactMu.Unlock()
|
||||
if existing, err := svc.store.Artifacts().Get(artifact.ID); err == nil {
|
||||
if existing.OwnerKind != artifact.OwnerKind || existing.OwnerID != artifact.OwnerID || existing.SizeBytes != artifact.SizeBytes || existing.Checksum != artifact.Checksum || existing.State != domain.ArtifactStateAvailable {
|
||||
return validationError("file upload idempotency key conflicts with existing artifact")
|
||||
}
|
||||
} else if !errors.Is(err, repo.ErrNotFound) {
|
||||
return err
|
||||
} else {
|
||||
if err := validator.ValidateArtifact(artifact); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := svc.store.Artifacts().Create(artifact); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := svc.artifactStore.PutPayload(artifact.ID, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
svc.artifactPayloads[artifact.ID] = domain.CopyBytes(payload)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanServerFileRelativePath(value string) string {
|
||||
value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
|
||||
if value == "" || value == "." {
|
||||
return ""
|
||||
}
|
||||
cleaned := path.Clean(value)
|
||||
if cleaned == "." {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimPrefix(cleaned, "/")
|
||||
}
|
||||
|
||||
func serverFileUploadArtifactID(serverInstanceID string, idempotencyKey string, relativePath string) string {
|
||||
raw := serverInstanceID + ":" + idempotencyKey + ":" + relativePath
|
||||
return "artifact-file-upload-" + sanitizeIDPart(serverInstanceID) + "-" + fmt.Sprint(stableStringNumber(raw))
|
||||
}
|
||||
|
||||
func fileManagerInlineInputRef(serverInstanceID string, key string, idempotencyKey string) string {
|
||||
return "input://server-files/" + sanitizeIDPart(serverInstanceID) + "/" + sanitizeIDPart(key) + "/" + fmt.Sprint(stableStringNumber(idempotencyKey))
|
||||
}
|
||||
|
||||
func fileRemark(file domain.PluginLogicalFile) string {
|
||||
if file.StreamKey != "" {
|
||||
return "日志流 " + file.StreamKey
|
||||
}
|
||||
if file.Editable {
|
||||
return "可编辑配置"
|
||||
}
|
||||
return "插件声明文件"
|
||||
}
|
||||
|
||||
func serverFileDisplayName(workspace domain.PluginFileWorkspace, key string) string {
|
||||
for _, file := range workspace.Files {
|
||||
if file.Key == key && strings.TrimSpace(file.Label) != "" {
|
||||
return file.Label
|
||||
}
|
||||
}
|
||||
name := path.Base(key)
|
||||
if name == "." || name == "/" || name == "" {
|
||||
return "server-file.txt"
|
||||
}
|
||||
return name
|
||||
}
|
||||
Reference in New Issue
Block a user