668 lines
30 KiB
Go
668 lines
30 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"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
|
||
serverFileDefaultDirectoryKey = "server-root"
|
||
serverFileBrowseWait = 4 * time.Second
|
||
)
|
||
|
||
type serverFileContext struct {
|
||
User domain.User
|
||
Instance domain.ServerInstance
|
||
Plugin domain.GamePlugin
|
||
Directory domain.PluginLogicalDirectory
|
||
Workspace domain.PluginFileWorkspace
|
||
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)
|
||
if err != nil {
|
||
return domain.ServerFileWorkspaceView{}, err
|
||
}
|
||
workspace := domain.CopyPluginFileWorkspace(ctx.Workspace)
|
||
view := domain.ServerFileWorkspaceView{
|
||
ServerInstanceID: ctx.Instance.ID,
|
||
PluginID: ctx.Plugin.ID,
|
||
DefaultDirectoryKey: workspace.DefaultDirectoryKey,
|
||
Directories: workspace.Directories,
|
||
Files: workspace.Files,
|
||
ConfigFields: workspace.ConfigFields,
|
||
DeclaredOnly: false,
|
||
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)
|
||
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
|
||
}
|
||
entries = filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Workspace, request.DirectoryKey), request.Query)
|
||
return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: "failed", Entries: entries, Job: latest, RefreshedAt: latest.TerminalAt, Reason: "Run 返回的文件列表无法解析。"}), nil
|
||
}
|
||
state := "declared"
|
||
reason := "展示服务器文件缓存;打开文件标签时会自动读取 Run 实时目录。"
|
||
if hasLatest {
|
||
if !isTerminalJobState(latest.State) {
|
||
state = "pending"
|
||
reason = "Run 正在刷新目录。"
|
||
} else if latest.State == domain.JobStateFailed || latest.State == domain.JobStateCancelled {
|
||
state = "failed"
|
||
reason = serverFileListJobFailureReason(latest)
|
||
}
|
||
}
|
||
entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Workspace, request.DirectoryKey), request.Query)
|
||
if state == "declared" {
|
||
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
|
||
}
|
||
|
||
func serverFileListJobFailureReason(job domain.Job) string {
|
||
prefix := "Run 文件目录刷新失败"
|
||
if job.State == domain.JobStateCancelled {
|
||
prefix = "Run 文件目录刷新已取消"
|
||
}
|
||
detail := strings.TrimSpace(job.CancelReason)
|
||
if detail == "" {
|
||
detail = strings.TrimSpace(job.Progress.Message)
|
||
}
|
||
if detail == "" {
|
||
detail = strings.TrimSpace(job.ExecutionResult.Summary)
|
||
}
|
||
if detail == "" {
|
||
return prefix + "。"
|
||
}
|
||
return prefix + ":" + detail
|
||
}
|
||
|
||
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)
|
||
if err != nil {
|
||
return domain.ServerFileListResult{}, err
|
||
}
|
||
endpoint, err := svc.GetRunEndpoint(ctx.Instance.RunEndpointID)
|
||
if err != nil {
|
||
return domain.ServerFileListResult{}, err
|
||
}
|
||
entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Workspace, request.DirectoryKey), request.Query)
|
||
if !containsString(endpoint.Capabilities, domain.JobCapabilityFilesList) {
|
||
return domain.CopyServerFileListResult(domain.ServerFileListResult{
|
||
ServerInstanceID: ctx.Instance.ID,
|
||
PluginID: ctx.Plugin.ID,
|
||
DirectoryKey: request.DirectoryKey,
|
||
Path: request.Path,
|
||
State: "ready",
|
||
Entries: entries,
|
||
Reason: "当前 Run 未声明 files.list,已展示插件声明的逻辑文件。",
|
||
}), nil
|
||
}
|
||
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), Deployment: deploymentPlanForDispatch(ctx.Instance.Deployment), 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
|
||
}
|
||
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) BrowseServerFilesForSession(ctx context.Context, sessionID string, request domain.ServerFileListRequest) (domain.ServerFileListResult, error) {
|
||
request = normalizeServerFileListRequest(request)
|
||
if request.IdempotencyKey == "" {
|
||
idempotencyKey, err := svc.serverFileBrowseIdempotencyKey(request)
|
||
if err != nil {
|
||
return domain.ServerFileListResult{}, err
|
||
}
|
||
request.IdempotencyKey = idempotencyKey
|
||
}
|
||
result, err := svc.RefreshServerFileListForSession(sessionID, request)
|
||
if err != nil || result.State != "pending" || result.Job.ID == "" {
|
||
return result, err
|
||
}
|
||
timer := time.NewTimer(serverFileBrowseWait)
|
||
defer timer.Stop()
|
||
for {
|
||
current, ready, err := svc.serverFileListResultFromJob(request, result, result.Job.ID)
|
||
if err != nil || ready {
|
||
return current, err
|
||
}
|
||
waiter := svc.registerRunJobWaiter(result.Job.RunEndpointID)
|
||
current, ready, err = svc.serverFileListResultFromJob(request, result, result.Job.ID)
|
||
if err != nil || ready {
|
||
svc.unregisterRunJobWaiter(result.Job.RunEndpointID, waiter)
|
||
return current, err
|
||
}
|
||
select {
|
||
case <-ctx.Done():
|
||
svc.unregisterRunJobWaiter(result.Job.RunEndpointID, waiter)
|
||
return domain.ServerFileListResult{}, ctx.Err()
|
||
case <-waiter:
|
||
case <-timer.C:
|
||
svc.unregisterRunJobWaiter(result.Job.RunEndpointID, waiter)
|
||
current.Reason = "正在读取目录。"
|
||
return current, nil
|
||
}
|
||
}
|
||
}
|
||
|
||
func (svc *CoreService) serverFileListResultFromJob(request domain.ServerFileListRequest, fallback domain.ServerFileListResult, jobID string) (domain.ServerFileListResult, bool, error) {
|
||
job, err := svc.store.Jobs().Get(jobID)
|
||
if err != nil {
|
||
return domain.ServerFileListResult{}, true, err
|
||
}
|
||
result := domain.CopyServerFileListResult(fallback)
|
||
result.Job = job
|
||
if !isTerminalJobState(job.State) {
|
||
return result, false, nil
|
||
}
|
||
result.RefreshedAt = job.TerminalAt
|
||
if job.State != domain.JobStateSucceeded || job.ExecutionResult.Kind != "file.list" {
|
||
result.State = "failed"
|
||
result.Reason = serverFileListJobFailureReason(job)
|
||
return result, true, nil
|
||
}
|
||
entries, err := serverFileEntriesFromRunList(job.ExecutionResult.Content, request.DirectoryKey, request.Path)
|
||
if err != nil {
|
||
result.State = "failed"
|
||
result.Reason = "Run 返回的文件列表无法解析。"
|
||
return result, true, nil
|
||
}
|
||
result.State = "ready"
|
||
result.Entries = filterServerFileEntries(entries, request.Query)
|
||
result.Reason = "目录读取完成。"
|
||
return result, true, nil
|
||
}
|
||
|
||
func (svc *CoreService) serverFileBrowseIdempotencyKey(request domain.ServerFileListRequest) (string, error) {
|
||
token, err := randomToken()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
raw := strings.Join([]string{request.ServerInstanceID, request.DirectoryKey, request.Path, request.Query, strconv.FormatBool(request.Recursive), token}, ":")
|
||
return fmt.Sprintf("file-browse:%d", stableStringNumber(raw)), 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)
|
||
if err != nil {
|
||
return domain.ServerFileUploadDispatch{}, err
|
||
}
|
||
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), Deployment: deploymentPlanForDispatch(ctx.Instance.Deployment), 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)
|
||
if err != nil {
|
||
return domain.ServerFileDownloadResult{}, err
|
||
}
|
||
filename := serverFileDisplayName(ctx.Workspace, 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) (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
|
||
}
|
||
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
|
||
}
|
||
workspace := effectiveServerFileWorkspace(plugin)
|
||
directory := domain.PluginLogicalDirectory{}
|
||
if directoryKey != "" {
|
||
var found bool
|
||
for _, candidate := range workspace.Directories {
|
||
if candidate.Key == directoryKey {
|
||
directory = candidate
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found && len(workspace.Directories) > 0 {
|
||
return serverFileContext{}, validationError("directoryKey must reference an available logical directory")
|
||
}
|
||
}
|
||
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 serverFileWorkspaceIsPluginDeclared(workspace) {
|
||
if workspace.DefaultDirectoryKey == "" && len(workspace.Directories) > 0 {
|
||
workspace.DefaultDirectoryKey = workspace.Directories[0].Key
|
||
}
|
||
return workspace
|
||
}
|
||
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 {
|
||
return workspace.DefaultDirectoryKey != "" || len(workspace.Directories) > 0 || len(workspace.Files) > 0 || len(workspace.ConfigFields) > 0
|
||
}
|
||
|
||
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: 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: true, 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 == "" && entry.Name != "" {
|
||
relativePath = cleanServerFileRelativePath(path.Join(fallbackPath, entry.Name))
|
||
} else if relativePath == "" {
|
||
relativePath = fallbackPath
|
||
}
|
||
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
|
||
}
|
||
|
||
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) {
|
||
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 != domain.JobCapabilityFilesList || job.TargetKey != directoryKey {
|
||
continue
|
||
}
|
||
if job.ExecutionInput.Inputs["path"] != relativePath {
|
||
continue
|
||
}
|
||
if !found || job.UpdatedAt.After(latest.UpdatedAt) || job.CreatedAt.After(latest.CreatedAt) {
|
||
latest = job
|
||
found = true
|
||
}
|
||
}
|
||
return domain.CopyJob(latest), found, nil
|
||
}
|
||
|
||
func isServerFileCapability(capability string) bool {
|
||
switch capability {
|
||
case domain.JobCapabilityFilesList, domain.JobCapabilityFilesRead, domain.JobCapabilityFilesWrite:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
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.cacheArtifactPayload(artifact.ID, 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
|
||
}
|