Make server file manager list-first
This commit is contained in:
@@ -1290,7 +1290,7 @@ func (h *coreHandlers) serverInstanceConfigApprove(w http.ResponseWriter, r *htt
|
|||||||
|
|
||||||
// serverFilesWorkspace godoc
|
// serverFilesWorkspace godoc
|
||||||
// @Summary Read server file workspace
|
// @Summary Read server file workspace
|
||||||
// @Description Returns plugin-declared logical directories and transfer policy without exposing host paths.
|
// @Description Returns the generic server file manager root and transfer policy without exposing host paths.
|
||||||
// @Tags server-files
|
// @Tags server-files
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param id path string true "Server instance ID"
|
// @Param id path string true "Server instance ID"
|
||||||
|
|||||||
@@ -260,18 +260,12 @@ func TestCoreAPIServerFileWorkspaceRoutesAreScoped(t *testing.T) {
|
|||||||
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, adminSession)
|
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, adminSession)
|
||||||
|
|
||||||
workspace := getJSONWithAuth[dto.ServerFileWorkspaceResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/workspace", adminSession)
|
workspace := getJSONWithAuth[dto.ServerFileWorkspaceResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/workspace", adminSession)
|
||||||
if workspace.DefaultDirectoryKey != "scum-config" || workspace.Transfer.Channel != "run-file-transfer" || len(workspace.Files) != 2 {
|
if workspace.DefaultDirectoryKey != "server-root" || workspace.Transfer.Channel != "run-file-transfer" || workspace.DeclaredOnly || len(workspace.Directories) != 1 || workspace.Directories[0].Label != "服务器根目录" || len(workspace.Files) != 0 {
|
||||||
t.Fatalf("unexpected workspace: %+v", workspace)
|
t.Fatalf("unexpected workspace: %+v", workspace)
|
||||||
}
|
}
|
||||||
list := getJSONWithAuth[dto.ServerFileListResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/list?directoryKey=scum-config", adminSession)
|
list := getJSONWithAuth[dto.ServerFileListResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/list?directoryKey=server-root", adminSession)
|
||||||
foundSettings := false
|
if list.State != "declared" || list.DirectoryKey != "server-root" || list.Entries == nil || !strings.Contains(list.Reason, "服务器文件缓存") {
|
||||||
for _, entry := range list.Entries {
|
t.Fatalf("expected generic root file list, got %+v", list)
|
||||||
if entry.LogicalKey == "scum-server-settings" && entry.Editable && entry.Downloadable {
|
|
||||||
foundSettings = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if list.State != "declared" || !foundSettings {
|
|
||||||
t.Fatalf("expected declared file list, got %+v", list)
|
|
||||||
}
|
}
|
||||||
readRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+instance.ID+"/files/read", dto.ServerFileReadRequest{PluginID: "server.scum", Key: "scum-server-settings", IdempotencyKey: "api-file-read"}, adminSession)
|
readRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+instance.ID+"/files/read", dto.ServerFileReadRequest{PluginID: "server.scum", Key: "scum-server-settings", IdempotencyKey: "api-file-read"}, adminSession)
|
||||||
assertStatus(t, readRecorder, http.StatusAccepted)
|
assertStatus(t, readRecorder, http.StatusAccepted)
|
||||||
@@ -300,11 +294,11 @@ func TestCoreAPIServerFileWorkspaceSynthesizesDefaultDirectoryForLegacyPlugin(t
|
|||||||
instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-file-legacy-api", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Legacy File API Server", State: domain.ServerInstanceStateRunning}, adminSession)
|
instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-file-legacy-api", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Legacy File API Server", State: domain.ServerInstanceStateRunning}, adminSession)
|
||||||
|
|
||||||
workspace := getJSONWithAuth[dto.ServerFileWorkspaceResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/workspace", adminSession)
|
workspace := getJSONWithAuth[dto.ServerFileWorkspaceResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/workspace", adminSession)
|
||||||
if workspace.DefaultDirectoryKey != "server-files" || workspace.DeclaredOnly || len(workspace.Directories) == 0 || workspace.Directories[0].Label != "服务器文件" || workspace.Directories == nil || workspace.Files == nil || workspace.ConfigFields == nil {
|
if workspace.DefaultDirectoryKey != "server-root" || workspace.DeclaredOnly || len(workspace.Directories) != 1 || workspace.Directories[0].Label != "服务器根目录" || workspace.Directories == nil || workspace.Files == nil || workspace.ConfigFields == nil {
|
||||||
t.Fatalf("expected synthesized non-null workspace, got %+v", workspace)
|
t.Fatalf("expected synthesized non-null workspace, got %+v", workspace)
|
||||||
}
|
}
|
||||||
list := getJSONWithAuth[dto.ServerFileListResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/list", adminSession)
|
list := getJSONWithAuth[dto.ServerFileListResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/list", adminSession)
|
||||||
if list.DirectoryKey != "server-files" || list.Entries == nil || !strings.Contains(list.Reason, "通用服务器文件目录") {
|
if list.DirectoryKey != "server-root" || list.Entries == nil || !strings.Contains(list.Reason, "服务器文件缓存") {
|
||||||
t.Fatalf("expected default file list, got %+v", list)
|
t.Fatalf("expected default file list, got %+v", list)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1935,6 +1935,9 @@ func (svc *CoreService) GetServerConfigForSession(sessionID string, serverInstan
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string, serverInstanceID string, fileKey string) (domain.DeclaredFileReadSnapshot, error) {
|
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)
|
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.DeclaredFileReadSnapshot{}, err
|
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")) {
|
if plugin.Status != domain.GamePluginStatusInstalled || (!plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read")) {
|
||||||
return domain.DeclaredFileReadSnapshot{}, ErrForbidden
|
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})
|
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.DeclaredFileReadSnapshot{}, err
|
return domain.DeclaredFileReadSnapshot{}, err
|
||||||
@@ -1958,7 +1957,7 @@ func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string,
|
|||||||
var pending *domain.Job
|
var pending *domain.Job
|
||||||
for i := range jobs {
|
for i := range jobs {
|
||||||
job := jobs[i]
|
job := jobs[i]
|
||||||
if job.Capability != domain.JobCapabilityFilesRead || job.TargetKey != file.Key {
|
if job.Capability != domain.JobCapabilityFilesRead || job.TargetKey != fileKey {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if job.State == domain.JobStateSucceeded && job.ExecutionResult.Kind == "file.read" {
|
if job.State == domain.JobStateSucceeded && job.ExecutionResult.Kind == "file.read" {
|
||||||
@@ -1973,7 +1972,7 @@ func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string,
|
|||||||
pending = ©
|
pending = ©
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 {
|
if completed != nil {
|
||||||
return domain.DeclaredFileReadSnapshot{
|
return domain.DeclaredFileReadSnapshot{
|
||||||
ServerInstanceID: base.ServerInstanceID,
|
ServerInstanceID: base.ServerInstanceID,
|
||||||
@@ -1995,7 +1994,7 @@ func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string,
|
|||||||
return base, nil
|
return base, nil
|
||||||
}
|
}
|
||||||
base.State = "not-read"
|
base.State = "not-read"
|
||||||
base.Reason = "尚未读取此声明文件。"
|
base.Reason = "尚未读取此文件。"
|
||||||
return base, nil
|
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") {
|
if request.Operation == domain.FileOperationWrite && !containsString(plugin.DeclaredPermissions, "server.files.write") {
|
||||||
return domain.FileOperationDispatchResult{}, ErrForbidden
|
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
|
capability := domain.JobCapabilityFilesRead
|
||||||
message := "file read queued"
|
message := "file read queued"
|
||||||
|
|||||||
@@ -907,7 +907,7 @@ func TestCoreServiceConfigWriteAndFileDispatchAreScoped(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDeclaredPluginFileWorkspaceConstrainsFileDispatch(t *testing.T) {
|
func TestPluginFileWorkspaceDoesNotConstrainServerFileDispatch(t *testing.T) {
|
||||||
svc := newTestCoreService()
|
svc := newTestCoreService()
|
||||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||||
plugin.FileWorkspace = scumTestFileWorkspace()
|
plugin.FileWorkspace = scumTestFileWorkspace()
|
||||||
@@ -946,16 +946,17 @@ func TestDeclaredPluginFileWorkspaceConstrainsFileDispatch(t *testing.T) {
|
|||||||
if allowed.Job.TargetKey != "scum-server-settings" || allowed.Job.Capability != domain.JobCapabilityFilesRead {
|
if allowed.Job.TargetKey != "scum-server-settings" || allowed.Job.Capability != domain.JobCapabilityFilesRead {
|
||||||
t.Fatalf("unexpected declared file dispatch: %+v", allowed)
|
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,
|
ServerInstanceID: instance.ID,
|
||||||
PluginID: plugin.ID,
|
PluginID: plugin.ID,
|
||||||
Operation: domain.FileOperationRead,
|
Operation: domain.FileOperationRead,
|
||||||
Key: "logs/latest.log",
|
Key: "logs/latest.log",
|
||||||
IdempotencyKey: "idem-file-workspace-unknown",
|
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,
|
ServerInstanceID: instance.ID,
|
||||||
PluginID: plugin.ID,
|
PluginID: plugin.ID,
|
||||||
Operation: domain.FileOperationWrite,
|
Operation: domain.FileOperationWrite,
|
||||||
@@ -963,8 +964,9 @@ func TestDeclaredPluginFileWorkspaceConstrainsFileDispatch(t *testing.T) {
|
|||||||
InputRef: "input://file-workspace/update",
|
InputRef: "input://file-workspace/update",
|
||||||
Content: "line",
|
Content: "line",
|
||||||
IdempotencyKey: "idem-file-workspace-log-write",
|
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") {
|
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)
|
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") {
|
unknownSnapshot, err := svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "logs/latest.log")
|
||||||
t.Fatalf("expected unknown logical key rejection, got %v", err)
|
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) {
|
if _, err := svc.GetDeclaredFileReadSnapshotForSession(otherSession, instance.ID, "scum-server-settings"); !errors.Is(err, ErrForbidden) {
|
||||||
t.Fatalf("expected unrelated session forbidden, got %v", err)
|
t.Fatalf("expected unrelated session forbidden, got %v", err)
|
||||||
|
|||||||
@@ -16,10 +16,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
serverFileTransferChannel = "run-file-transfer"
|
serverFileTransferChannel = "run-file-transfer"
|
||||||
serverFileMaxInlineEditBytes = 64 * 1024
|
serverFileMaxInlineEditBytes = 64 * 1024
|
||||||
serverFileDefaultDirectoryKey = "server-files"
|
serverFileDefaultDirectoryKey = "server-root"
|
||||||
serverFileDefaultLogDirectoryKey = "server-logs"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type serverFileContext struct {
|
type serverFileContext struct {
|
||||||
@@ -65,7 +64,7 @@ func (svc *CoreService) GetServerFileWorkspaceForSession(sessionID string, serve
|
|||||||
Directories: workspace.Directories,
|
Directories: workspace.Directories,
|
||||||
Files: workspace.Files,
|
Files: workspace.Files,
|
||||||
ConfigFields: workspace.ConfigFields,
|
ConfigFields: workspace.ConfigFields,
|
||||||
DeclaredOnly: serverFileWorkspaceIsPluginDeclared(ctx.Plugin.FileWorkspace),
|
DeclaredOnly: false,
|
||||||
RuntimeWorkspaceScope: svc.runtimeProfileScope(ctx.Instance.ID),
|
RuntimeWorkspaceScope: svc.runtimeProfileScope(ctx.Instance.ID),
|
||||||
Transfer: domain.ServerFileTransferPolicy{
|
Transfer: domain.ServerFileTransferPolicy{
|
||||||
Channel: serverFileTransferChannel,
|
Channel: serverFileTransferChannel,
|
||||||
@@ -102,18 +101,14 @@ func (svc *CoreService) ListServerFilesForSession(sessionID string, request doma
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
state := "declared"
|
state := "declared"
|
||||||
reason := "展示插件声明的逻辑文件;点击刷新可请求 Run 返回实时目录。"
|
reason := "展示服务器文件缓存;点击刷新目录读取 Run 实时目录。"
|
||||||
if hasLatest && !isTerminalJobState(latest.State) {
|
if hasLatest && !isTerminalJobState(latest.State) {
|
||||||
state = "pending"
|
state = "pending"
|
||||||
reason = "Run 正在刷新目录。"
|
reason = "Run 正在刷新目录。"
|
||||||
}
|
}
|
||||||
entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Workspace, request.DirectoryKey), request.Query)
|
entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Workspace, request.DirectoryKey), request.Query)
|
||||||
if state == "declared" {
|
if state == "declared" {
|
||||||
reasonPrefix := "展示插件声明的逻辑文件"
|
reason = "展示服务器文件缓存;点击刷新目录读取 Run 实时目录。"
|
||||||
if !serverFileWorkspaceIsPluginDeclared(ctx.Plugin.FileWorkspace) {
|
|
||||||
reasonPrefix = "展示通用服务器文件目录"
|
|
||||||
}
|
|
||||||
reason = reasonPrefix + ";点击刷新可请求 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
|
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 {
|
if err != nil {
|
||||||
return domain.ServerFileUploadDispatch{}, err
|
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))
|
relativePath := cleanServerFileRelativePath(path.Join(request.RelativePath, request.Filename))
|
||||||
artifactID := serverFileUploadArtifactID(request.ServerInstanceID, request.IdempotencyKey, relativePath)
|
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()}
|
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
|
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 {
|
func effectiveServerFileWorkspace(_ domain.GamePlugin) domain.PluginFileWorkspace {
|
||||||
workspace := domain.CopyPluginFileWorkspace(plugin.FileWorkspace)
|
return domain.PluginFileWorkspace{DefaultDirectoryKey: serverFileDefaultDirectoryKey, Directories: []domain.PluginLogicalDirectory{{Key: serverFileDefaultDirectoryKey, Label: "服务器根目录", Scope: "config"}}, Files: []domain.PluginLogicalFile{}, ConfigFields: []domain.PluginConfigField{}}
|
||||||
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 serverFileWorkspaceIsPluginDeclared(workspace domain.PluginFileWorkspace) bool {
|
func serverFileWorkspaceIsPluginDeclared(workspace domain.PluginFileWorkspace) bool {
|
||||||
@@ -380,13 +361,13 @@ func serverFileEntriesFromDeclaredWorkspace(workspace domain.PluginFileWorkspace
|
|||||||
if directoryKey == "" || directory.Key == directoryKey {
|
if directoryKey == "" || directory.Key == directoryKey {
|
||||||
continue
|
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 {
|
for _, file := range workspace.Files {
|
||||||
if directoryKey != "" && file.DirectoryKey != directoryKey {
|
if directoryKey != "" && file.DirectoryKey != directoryKey {
|
||||||
continue
|
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 {
|
sort.SliceStable(entries, func(i int, j int) bool {
|
||||||
if entries[i].Kind == entries[j].Kind {
|
if entries[i].Kind == entries[j].Kind {
|
||||||
@@ -417,10 +398,14 @@ func serverFileEntriesFromRunList(content string, fallbackDirectoryKey string, f
|
|||||||
directoryKey = fallbackDirectoryKey
|
directoryKey = fallbackDirectoryKey
|
||||||
}
|
}
|
||||||
relativePath := cleanServerFileRelativePath(entry.RelativePath)
|
relativePath := cleanServerFileRelativePath(entry.RelativePath)
|
||||||
if relativePath == "" {
|
if relativePath == "" && entry.Name != "" {
|
||||||
|
relativePath = cleanServerFileRelativePath(path.Join(fallbackPath, entry.Name))
|
||||||
|
} else if relativePath == "" {
|
||||||
relativePath = fallbackPath
|
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
|
return entries, nil
|
||||||
}
|
}
|
||||||
@@ -518,7 +503,7 @@ func fileRemark(file domain.PluginLogicalFile) string {
|
|||||||
if file.Editable {
|
if file.Editable {
|
||||||
return "可编辑配置"
|
return "可编辑配置"
|
||||||
}
|
}
|
||||||
return "插件声明文件"
|
return "文件"
|
||||||
}
|
}
|
||||||
|
|
||||||
func serverFileDisplayName(workspace domain.PluginFileWorkspace, key string) string {
|
func serverFileDisplayName(workspace domain.PluginFileWorkspace, key string) string {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"browser.local/platform/domain"
|
"browser.local/platform/domain"
|
||||||
@@ -24,7 +25,7 @@ const (
|
|||||||
maxProgressMessageLength = 256
|
maxProgressMessageLength = 256
|
||||||
maxServerConfigContentSize = 64 * 1024
|
maxServerConfigContentSize = 64 * 1024
|
||||||
maxJobExecutionContentSize = 64 * 1024
|
maxJobExecutionContentSize = 64 * 1024
|
||||||
maxLogicalFileKeyLength = 160
|
maxLogicalFileKeyLength = 1024
|
||||||
maxProductionMessageLength = 320
|
maxProductionMessageLength = 320
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1444,6 +1445,16 @@ func ValidateServerFileReadRequest(request domain.ServerFileReadRequest) error {
|
|||||||
return ValidateFileOperationDispatchRequest(domain.FileOperationDispatchRequest{ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: domain.FileOperationRead, Key: request.Key, IdempotencyKey: request.IdempotencyKey})
|
return ValidateFileOperationDispatchRequest(domain.FileOperationDispatchRequest{ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: domain.FileOperationRead, Key: request.Key, IdempotencyKey: request.IdempotencyKey})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ValidateServerFileReadSnapshotRequest(serverInstanceID string, key string) error {
|
||||||
|
var violations []string
|
||||||
|
violations = appendRequired(violations, "serverInstanceId", serverInstanceID)
|
||||||
|
violations = appendRequired(violations, "key", key)
|
||||||
|
if !validLogicalFileKey(key) {
|
||||||
|
violations = append(violations, "key is not allowed")
|
||||||
|
}
|
||||||
|
return finish(violations)
|
||||||
|
}
|
||||||
|
|
||||||
func ValidateServerFileWriteRequest(request domain.ServerFileWriteRequest) error {
|
func ValidateServerFileWriteRequest(request domain.ServerFileWriteRequest) error {
|
||||||
return ValidateFileOperationDispatchRequest(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})
|
return ValidateFileOperationDispatchRequest(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})
|
||||||
}
|
}
|
||||||
@@ -2412,14 +2423,13 @@ func validFileOperationKind(operation domain.FileOperationKind) bool {
|
|||||||
|
|
||||||
func validUploadFilename(name string) bool {
|
func validUploadFilename(name string) bool {
|
||||||
trimmed := strings.TrimSpace(name)
|
trimmed := strings.TrimSpace(name)
|
||||||
if trimmed == "" || trimmed != name || len([]rune(name)) > 120 || strings.Contains(name, "/") || strings.Contains(name, `\`) || strings.Contains(name, "..") || strings.Contains(name, "://") || looksLikeRawHostPath(name) || containsUnsafeRuntimeSecret(name) {
|
if trimmed == "" || trimmed != name || name == "." || name == ".." || len([]rune(name)) > 255 || !utf8.ValidString(name) || strings.Contains(name, "/") || strings.Contains(name, `\`) || strings.Contains(name, ":") || strings.Contains(name, "://") || looksLikeRawHostPath(name) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, char := range name {
|
for _, char := range name {
|
||||||
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '.' || char == ' ' || char == '(' || char == ')' {
|
if unicode.IsControl(char) || char == 0 {
|
||||||
continue
|
return false
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -2438,14 +2448,18 @@ func validLogicalFileKey(key string) bool {
|
|||||||
if trimmed == "" || trimmed != key || len([]rune(key)) > maxLogicalFileKeyLength {
|
if trimmed == "" || trimmed != key || len([]rune(key)) > maxLogicalFileKeyLength {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(key, "/") || strings.Contains(key, "..") || strings.Contains(key, `\`) || strings.Contains(key, "://") || looksLikeRawHostPath(key) || containsUnsafeRuntimeSecret(key) {
|
if !utf8.ValidString(key) || strings.HasPrefix(key, "/") || strings.Contains(key, `\`) || strings.Contains(key, ":") || strings.Contains(key, "://") || looksLikeRawHostPath(key) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
for _, segment := range strings.Split(key, "/") {
|
||||||
|
if segment == "" || segment == "." || segment == ".." {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, char := range key {
|
for _, char := range key {
|
||||||
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '.' || char == '/' {
|
if unicode.IsControl(char) || char == 0 {
|
||||||
continue
|
return false
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia
|
|||||||
- `startServerInstance` and `stopServerInstance` post `ServerLifecycleCommandRequest` with the current config version and receive the lifecycle job response.
|
- `startServerInstance` and `stopServerInstance` post `ServerLifecycleCommandRequest` with the current config version and receive the lifecycle job response.
|
||||||
- `listServerAdministratorCandidates`, `addServerAdministrator`, and `removeServerAdministrator` call server membership endpoints so server owners can invite or remove active non-platform-admin server administrators.
|
- `listServerAdministratorCandidates`, `addServerAdministrator`, and `removeServerAdministrator` call server membership endpoints so server owners can invite or remove active non-platform-admin server administrators.
|
||||||
- Game-specific pages use the scoped `plugin-data` collection API and declared plugin bridge machine actions; Platform does not expose game-specific projection or workflow clients.
|
- Game-specific pages use the scoped `plugin-data` collection API and declared plugin bridge machine actions; Platform does not expose game-specific projection or workflow clients.
|
||||||
- `dispatchFileOperation` posts `FileOperationDispatchRequest` to `/file-operations/dispatch` using logical file keys and scoped refs rather than raw host paths; it remains the low-level compatibility dispatch for plugin-declared file work.
|
- `dispatchFileOperation` posts `FileOperationDispatchRequest` to `/file-operations/dispatch` using logical file keys and scoped refs rather than raw host paths; it remains the low-level compatibility dispatch for file work.
|
||||||
- `getServerFileWorkspace`, `listServerFiles`, `refreshServerFiles`, `readServerFile`, `getServerFileReadSnapshot`, `writeServerFile`, `uploadServerFile`, and `prepareServerFileDownload` power the first-party server-detail file manager. The page renders plugin-declared logical directories, requests live listings through `files.list`, reads editable snapshots through `files.read`, saves through `files.write`, and stages browser uploads as server-instance artifacts before Run pulls input chunks on the dedicated file-transfer channel. Server detail may call only these server-file APIs plus the encapsulated download helper; it must not call raw artifact-transfer methods directly.
|
- `getServerFileWorkspace`, `listServerFiles`, `refreshServerFiles`, `readServerFile`, `getServerFileReadSnapshot`, `writeServerFile`, `uploadServerFile`, and `prepareServerFileDownload` power the first-party server-detail file manager. The page renders a generic server-root entry, requests live listings through `files.list`, reads snapshots through `files.read`, saves through `files.write`, and stages browser uploads as server-instance artifacts before Run pulls input chunks on the dedicated file-transfer channel. Server detail may call only these server-file APIs plus the encapsulated download helper; it must not call raw artifact-transfer methods directly.
|
||||||
- `listArtifacts`, `openArtifactDownload`, and `readArtifactContent` use platform artifact routes for available job/server artifacts. Browser reads are chunked through `/artifacts/{id}/content` and must render only safe filenames, checksums, progress, and platform storage behavior.
|
- `listArtifacts`, `openArtifactDownload`, and `readArtifactContent` use platform artifact routes for available job/server artifacts. Browser reads are chunked through `/artifacts/{id}/content` and must render only safe filenames, checksums, progress, and platform storage behavior.
|
||||||
- `authorizePluginBridge` posts `PluginBridgeAuthorizeRequest` to `/plugin-bridge/authorize` for preflight decisions.
|
- `authorizePluginBridge` posts `PluginBridgeAuthorizeRequest` to `/plugin-bridge/authorize` for preflight decisions.
|
||||||
- `executePluginBridge` posts `PluginBridgeExecuteRequest` to `/plugin-bridge/execute` from host-owned bridge dispatch utilities only. Plugin pages receive typed `PluginBridgeExecuteResponse` envelopes and never receive the platform API client, bearer token, raw provider key, run socket, host path, or storage credential.
|
- `executePluginBridge` posts `PluginBridgeExecuteRequest` to `/plugin-bridge/execute` from host-owned bridge dispatch utilities only. Plugin pages receive typed `PluginBridgeExecuteResponse` envelopes and never receive the platform API client, bearer token, raw provider key, run socket, host path, or storage credential.
|
||||||
|
|||||||
@@ -85,6 +85,16 @@ describe("ServerDetailPage config write approval", () => {
|
|||||||
expect(serverDetailPageSource).not.toContain("Bearer ");
|
expect(serverDetailPageSource).not.toContain("Bearer ");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the server file manager list-first without plugin declaration gates", () => {
|
||||||
|
expect(serverDetailPageSource).toContain("server-file-editor-overlay");
|
||||||
|
expect(serverDetailPageSource).not.toContain("server-file-layout");
|
||||||
|
expect(serverDetailPageSource).not.toContain("未声明目录");
|
||||||
|
expect(serverDetailPageSource).not.toContain("插件尚未声明");
|
||||||
|
expect(serverDetailPageSource).not.toContain("插件声明为可编辑");
|
||||||
|
expect(serverDetailPageSource).not.toContain("entry.editable");
|
||||||
|
expect(serverDetailPageSource).not.toContain("entry.downloadable");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps run distribution and client-manager workflows out of server detail tabs", () => {
|
it("keeps run distribution and client-manager workflows out of server detail tabs", () => {
|
||||||
expect(serverDetailPageSource).not.toContain('id="run-builder"');
|
expect(serverDetailPageSource).not.toContain('id="run-builder"');
|
||||||
expect(serverDetailPageSource).not.toContain("getServerRuntimeActions");
|
expect(serverDetailPageSource).not.toContain("getServerRuntimeActions");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ChevronRight, Download, Eye, FileText, Folder, MoonStar, PackageOpen, Pencil, RefreshCw, Save, Search, ShieldCheck, Sparkles, Square, Terminal, Upload, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
|
import { ChevronRight, Download, Eye, FileText, Folder, MoonStar, PackageOpen, Pencil, RefreshCw, Save, Search, ShieldCheck, Sparkles, Square, Terminal, Upload, UserRoundMinus, UserRoundPlus, WandSparkles, X } from "lucide-react";
|
||||||
import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { platformApiClient } from "../api/client";
|
import { platformApiClient } from "../api/client";
|
||||||
@@ -571,7 +571,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
|
|||||||
const [editor, setEditor] = useState<ServerFileEditorState>({ entry: null, key: "", draft: "", loading: false, saving: false });
|
const [editor, setEditor] = useState<ServerFileEditorState>({ entry: null, key: "", draft: "", loading: false, saving: false });
|
||||||
|
|
||||||
const activeDirectory = workspace.status === "ready" ? workspace.data.directories.find((item) => item.key === directoryKey) : undefined;
|
const activeDirectory = workspace.status === "ready" ? workspace.data.directories.find((item) => item.key === directoryKey) : undefined;
|
||||||
const canUpload = workspace.status === "ready" && Boolean(activeDirectory) && activeDirectory?.scope !== "logs" && !uploadBusy;
|
const canUpload = workspace.status === "ready" && Boolean(activeDirectory) && !uploadBusy;
|
||||||
const entries = list.status === "ready" ? list.data.entries : [];
|
const entries = list.status === "ready" ? list.data.entries : [];
|
||||||
|
|
||||||
const loadWorkspace = useCallback(async () => {
|
const loadWorkspace = useCallback(async () => {
|
||||||
@@ -581,9 +581,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
|
|||||||
setWorkspace({ status: "ready", data: response });
|
setWorkspace({ status: "ready", data: response });
|
||||||
const nextDirectoryKey = response.defaultDirectoryKey || response.directories[0]?.key || "";
|
const nextDirectoryKey = response.defaultDirectoryKey || response.directories[0]?.key || "";
|
||||||
setDirectoryKey((current) => current || nextDirectoryKey);
|
setDirectoryKey((current) => current || nextDirectoryKey);
|
||||||
if (!nextDirectoryKey) {
|
if (!nextDirectoryKey) setList({ status: "ready", data: { serverInstanceId: response.serverInstanceId, pluginId: response.pluginId, directoryKey: "", state: "declared", entries: [], reason: "尚未获得服务器文件入口。" } });
|
||||||
setList({ status: "ready", data: { serverInstanceId: response.serverInstanceId, pluginId: response.pluginId, directoryKey: "", state: "declared", entries: [], reason: "插件尚未声明文件工作区;需要在插件 manifest 中添加 fileWorkspace。" } });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setWorkspace({ status: "error", reason: error instanceof Error ? error.message : "文件工作区加载失败" });
|
setWorkspace({ status: "error", reason: error instanceof Error ? error.message : "文件工作区加载失败" });
|
||||||
setList({ status: "error", reason: "文件工作区不可用" });
|
setList({ status: "error", reason: "文件工作区不可用" });
|
||||||
@@ -639,7 +637,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
|
|||||||
async function openEditor(entry: ServerFileEntryResponse) {
|
async function openEditor(entry: ServerFileEntryResponse) {
|
||||||
const key = serverFileEntryKey(entry);
|
const key = serverFileEntryKey(entry);
|
||||||
if (!key) {
|
if (!key) {
|
||||||
setPanelResult({ status: "failed", label: "该文件缺少插件声明的逻辑 key,不能读取。" });
|
setPanelResult({ status: "failed", label: "该文件缺少路径 key,不能读取。" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setEditor({ entry, key, draft: "", loading: true, saving: false, message: "正在读取最近快照…" });
|
setEditor({ entry, key, draft: "", loading: true, saving: false, message: "正在读取最近快照…" });
|
||||||
@@ -662,7 +660,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveEditor() {
|
async function saveEditor() {
|
||||||
if (!editor.entry || !editor.key || editor.saving || !editor.entry.editable) return;
|
if (!editor.entry || !editor.key || editor.saving) return;
|
||||||
const operationId = operations.begin({ intent: "保存文件", targetKind: "server", targetId: instance.id, requester: session.displayName });
|
const operationId = operations.begin({ intent: "保存文件", targetKind: "server", targetId: instance.id, requester: session.displayName });
|
||||||
setEditor((current) => ({ ...current, saving: true, error: undefined, message: "正在派发写入任务…" }));
|
setEditor((current) => ({ ...current, saving: true, error: undefined, message: "正在派发写入任务…" }));
|
||||||
try {
|
try {
|
||||||
@@ -681,7 +679,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
|
|||||||
|
|
||||||
async function downloadEntry(entry: ServerFileEntryResponse) {
|
async function downloadEntry(entry: ServerFileEntryResponse) {
|
||||||
const key = serverFileEntryKey(entry);
|
const key = serverFileEntryKey(entry);
|
||||||
if (!key || !entry.downloadable) return;
|
if (!key) return;
|
||||||
const operationId = operations.begin({ intent: "下载文件", targetKind: "server", targetId: instance.id, requester: session.displayName });
|
const operationId = operations.begin({ intent: "下载文件", targetKind: "server", targetId: instance.id, requester: session.displayName });
|
||||||
setPanelResult({ status: "pending", label: "正在准备文件下载…" });
|
setPanelResult({ status: "pending", label: "正在准备文件下载…" });
|
||||||
try {
|
try {
|
||||||
@@ -736,6 +734,10 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeEditor() {
|
||||||
|
setEditor({ entry: null, key: "", draft: "", loading: false, saving: false });
|
||||||
|
}
|
||||||
|
|
||||||
if (workspace.status === "loading") return <LoadingState label="正在加载文件工作区…" compact />;
|
if (workspace.status === "loading") return <LoadingState label="正在加载文件工作区…" compact />;
|
||||||
if (workspace.status === "error") return <ErrorState title="文件工作区不可用" reason={workspace.reason} diagnosticId={`server-files:${instance.id}`} onRetry={() => void loadWorkspace()} compact />;
|
if (workspace.status === "error") return <ErrorState title="文件工作区不可用" reason={workspace.reason} diagnosticId={`server-files:${instance.id}`} onRetry={() => void loadWorkspace()} compact />;
|
||||||
|
|
||||||
@@ -747,18 +749,17 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
|
|||||||
</div>
|
</div>
|
||||||
<div className="server-file-pathbar" aria-label="当前文件路径">
|
<div className="server-file-pathbar" aria-label="当前文件路径">
|
||||||
<button type="button" className="icon-command" onClick={goUp} disabled={!relativePath && directoryKey === workspace.data.defaultDirectoryKey}><ChevronRight size={14} className="server-file-back-icon" /><span>上级</span></button>
|
<button type="button" className="icon-command" onClick={goUp} disabled={!relativePath && directoryKey === workspace.data.defaultDirectoryKey}><ChevronRight size={14} className="server-file-back-icon" /><span>上级</span></button>
|
||||||
<span className="server-file-path-chip">{activeDirectory?.label ?? (directoryKey || "未声明目录")}</span>
|
<span className="server-file-path-chip">{activeDirectory?.label ?? (directoryKey || "服务器根目录")}</span>
|
||||||
{relativePath.split("/").filter(Boolean).map((part) => <span key={part} className="server-file-path-chip server-file-path-child"><ChevronRight size={12} />{part}</span>)}
|
{relativePath.split("/").filter(Boolean).map((part) => <span key={part} className="server-file-path-chip server-file-path-child"><ChevronRight size={12} />{part}</span>)}
|
||||||
</div>
|
</div>
|
||||||
<div className="server-file-toolbar">
|
<div className={cx("server-file-toolbar", workspace.data.directories.length > 1 && "server-file-toolbar-has-tabs")}>
|
||||||
<div className="server-file-directory-tabs" role="tablist" aria-label="文件目录">
|
{workspace.data.directories.length > 1 && <div className="server-file-directory-tabs" role="tablist" aria-label="文件目录">
|
||||||
{workspace.data.directories.length === 0 && <span className="provider-id">插件尚未声明可浏览目录</span>}
|
|
||||||
{workspace.data.directories.map((directory) => (
|
{workspace.data.directories.map((directory) => (
|
||||||
<button key={directory.key} type="button" className={cx("segmented-button", directory.key === directoryKey && "segmented-button-active")} onClick={() => { setDirectoryKey(directory.key); setRelativePath(""); }}>
|
<button key={directory.key} type="button" className={cx("segmented-button", directory.key === directoryKey && "segmented-button-active")} onClick={() => { setDirectoryKey(directory.key); setRelativePath(""); }}>
|
||||||
{directory.label}
|
{directory.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>}
|
||||||
<form className="server-file-search" onSubmit={submitSearch}>
|
<form className="server-file-search" onSubmit={submitSearch}>
|
||||||
<Search size={14} />
|
<Search size={14} />
|
||||||
<input type="search" value={searchDraft} placeholder="搜索文件/目录" onChange={(event) => setSearchDraft(event.target.value)} />
|
<input type="search" value={searchDraft} placeholder="搜索文件/目录" onChange={(event) => setSearchDraft(event.target.value)} />
|
||||||
@@ -767,7 +768,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
|
|||||||
</form>
|
</form>
|
||||||
<div className="action-strip server-file-actions">
|
<div className="action-strip server-file-actions">
|
||||||
<button type="button" className="icon-command" onClick={() => void refreshRuntimeList()} disabled={!directoryKey}><RefreshCw size={14} /><span>刷新目录</span></button>
|
<button type="button" className="icon-command" onClick={() => void refreshRuntimeList()} disabled={!directoryKey}><RefreshCw size={14} /><span>刷新目录</span></button>
|
||||||
<label className={cx("server-file-upload-control", !canUpload && "server-file-upload-disabled")} title={canUpload ? "上传到当前逻辑目录" : "当前目录不可上传或正在上传"}>
|
<label className={cx("server-file-upload-control", !canUpload && "server-file-upload-disabled")} title={canUpload ? "上传到当前目录" : "当前目录不可上传或正在上传"}>
|
||||||
<Upload size={14} /><span>{uploadBusy ? "上传中…" : "上传"}</span><input type="file" disabled={!canUpload} onChange={(event) => void uploadFile(event)} />
|
<Upload size={14} /><span>{uploadBusy ? "上传中…" : "上传"}</span><input type="file" disabled={!canUpload} onChange={(event) => void uploadFile(event)} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -776,38 +777,37 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
|
|||||||
{list.status === "loading" && <LoadingState label="正在加载文件列表…" compact />}
|
{list.status === "loading" && <LoadingState label="正在加载文件列表…" compact />}
|
||||||
{list.status === "error" && <ErrorState title="文件列表不可用" reason={list.reason} diagnosticId={`server-file-list:${instance.id}:${directoryKey}`} onRetry={() => void loadList()} compact />}
|
{list.status === "error" && <ErrorState title="文件列表不可用" reason={list.reason} diagnosticId={`server-file-list:${instance.id}:${directoryKey}`} onRetry={() => void loadList()} compact />}
|
||||||
{list.status === "ready" && (
|
{list.status === "ready" && (
|
||||||
<div className="server-file-layout">
|
<div className="resource-table-wrap server-file-table-wrap">
|
||||||
<div className="resource-table-wrap server-file-table-wrap">
|
|
||||||
<table className="resource-table server-file-table">
|
<table className="resource-table server-file-table">
|
||||||
<thead><tr><th aria-label="选择"><input type="checkbox" disabled /></th><th>文件名称</th><th>大小</th><th>修改时间</th><th>备注</th><th>操作</th></tr></thead>
|
<thead><tr><th>文件名称</th><th>大小</th><th>修改时间</th><th>备注</th><th>操作</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{entries.length === 0 && <tr><td colSpan={6}><span className="provider-id">当前目录没有可展示文件;可刷新实时目录或换一个插件声明目录。</span></td></tr>}
|
{entries.length === 0 && <tr><td colSpan={5}><span className="provider-id">当前目录暂无缓存结果;点击刷新目录读取实时文件。</span></td></tr>}
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
<tr key={serverFileEntryRowKey(entry)} className={entry.kind === "directory" ? "server-file-directory-row" : undefined}>
|
<tr key={serverFileEntryRowKey(entry)} className={entry.kind === "directory" ? "server-file-directory-row" : undefined}>
|
||||||
<td><input type="checkbox" disabled /></td>
|
|
||||||
<td>
|
<td>
|
||||||
<button type="button" className="table-link-button server-file-name-button" onClick={() => void openEntry(entry)}>
|
<button type="button" className="table-link-button server-file-name-button" onClick={() => void openEntry(entry)}>
|
||||||
{entry.kind === "directory" ? <Folder size={16} /> : <FileText size={16} />}<span>{entry.name}</span>
|
{entry.kind === "directory" ? <Folder size={16} /> : <FileText size={16} />}<span>{entry.name}</span>
|
||||||
</button>
|
</button>
|
||||||
<span className="provider-id">{entry.logicalKey || entry.relativePath || entry.directoryKey}</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td>{entry.kind === "directory" ? "计算" : formatBytes(entry.sizeBytes)}</td>
|
<td>{entry.kind === "directory" ? "--" : formatBytes(entry.sizeBytes)}</td>
|
||||||
<td>{formatDateTime(entry.modifiedAt)}</td>
|
<td>{formatDateTime(entry.modifiedAt)}</td>
|
||||||
<td>{entry.remark || entry.scope || "--"}</td>
|
<td>{entry.remark || entry.scope || "--"}</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="row-actions human-row-actions">
|
<div className="row-actions human-row-actions">
|
||||||
{entry.kind === "directory" ? <button type="button" title="打开目录" onClick={() => void openEntry(entry)}><Eye size={14} /><span>打开</span></button> : <button type="button" title="读取/编辑" disabled={!entry.editable} onClick={() => void openEditor(entry)}><Pencil size={14} /><span>编辑</span></button>}
|
{entry.kind === "directory" ? <button type="button" title="打开目录" onClick={() => void openEntry(entry)}><Eye size={14} /><span>打开</span></button> : <button type="button" title="读取/编辑" onClick={() => void openEditor(entry)}><Pencil size={14} /><span>编辑</span></button>}
|
||||||
{entry.kind === "file" && <button type="button" title="下载" disabled={!entry.downloadable} onClick={() => void downloadEntry(entry)}><Download size={14} /><span>下载</span></button>}
|
{entry.kind === "file" && <button type="button" title="下载" onClick={() => void downloadEntry(entry)}><Download size={14} /><span>下载</span></button>}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<aside className="server-file-editor" aria-label="file editor">
|
)}
|
||||||
<div className="panel-header"><h3><FileText size={15} style={{ verticalAlign: "-2px" }} /> 文件编辑</h3>{editor.entry && <span className="page-status">{editor.entry.name}</span>}</div>
|
{editor.entry && (
|
||||||
{!editor.entry && <p className="section-copy">选择一个插件声明为可编辑的文件后,这里会显示最近读取快照;保存会派发 Run 侧写入任务。</p>}
|
<div className="server-file-editor-overlay" role="dialog" aria-modal="true" aria-label="file editor">
|
||||||
|
<section className="server-file-editor">
|
||||||
|
<div className="panel-header"><h3><FileText size={15} style={{ verticalAlign: "-2px" }} /> 文件编辑</h3><span className="page-status">{editor.entry.name}</span><button type="button" className="icon-command" onClick={closeEditor}><X size={14} /><span>关闭</span></button></div>
|
||||||
{editor.entry && editor.loading && <LoadingState label="正在读取文件快照…" compact />}
|
{editor.entry && editor.loading && <LoadingState label="正在读取文件快照…" compact />}
|
||||||
{editor.entry && editor.error && <ErrorState title="文件编辑不可用" reason={editor.error} diagnosticId={`server-file-edit:${instance.id}:${editor.key}`} compact />}
|
{editor.entry && editor.error && <ErrorState title="文件编辑不可用" reason={editor.error} diagnosticId={`server-file-edit:${instance.id}:${editor.key}`} compact />}
|
||||||
{editor.entry && editor.message && !editor.error && <span className="provider-id">{editor.message}</span>}
|
{editor.entry && editor.message && !editor.error && <span className="provider-id">{editor.message}</span>}
|
||||||
@@ -819,11 +819,11 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
|
|||||||
)}
|
)}
|
||||||
{editor.entry && (
|
{editor.entry && (
|
||||||
<div className="action-strip server-file-editor-actions">
|
<div className="action-strip server-file-editor-actions">
|
||||||
<button type="button" className="primary-command" disabled={!editor.entry.editable || editor.loading || editor.saving || editor.snapshot?.content === undefined} onClick={() => void saveEditor()}><Save size={14} /><span>{editor.saving ? "保存中…" : "保存"}</span></button>
|
<button type="button" className="primary-command" disabled={editor.loading || editor.saving || editor.snapshot?.content === undefined} onClick={() => void saveEditor()}><Save size={14} /><span>{editor.saving ? "保存中…" : "保存"}</span></button>
|
||||||
<button type="button" className="icon-command" disabled={!editor.entry.downloadable} onClick={() => void downloadEntry(editor.entry!)}><Download size={14} /><span>下载</span></button>
|
<button type="button" className="icon-command" onClick={() => void downloadEntry(editor.entry!)}><Download size={14} /><span>下载</span></button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -455,7 +455,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
|
|||||||
.server-file-back-icon{transform:rotate(180deg)}
|
.server-file-back-icon{transform:rotate(180deg)}
|
||||||
.server-file-path-chip{display:inline-flex;align-items:center;gap:4px;min-height:30px;padding:0 10px;border:1px solid var(--line);border-radius:999px;background:var(--control-surface);color:var(--ink-soft);font-size:12px;font-weight:800;white-space:nowrap}
|
.server-file-path-chip{display:inline-flex;align-items:center;gap:4px;min-height:30px;padding:0 10px;border:1px solid var(--line);border-radius:999px;background:var(--control-surface);color:var(--ink-soft);font-size:12px;font-weight:800;white-space:nowrap}
|
||||||
.server-file-path-child{color:var(--ink-faint)}
|
.server-file-path-child{color:var(--ink-faint)}
|
||||||
.server-file-toolbar{display:grid;grid-template-columns:minmax(180px,1fr) minmax(260px,1.2fr) auto;gap:8px;align-items:center;min-width:0}
|
.server-file-toolbar{display:grid;grid-template-columns:minmax(320px,1fr) auto;gap:8px;align-items:center;min-width:0}.server-file-toolbar-has-tabs{grid-template-columns:minmax(160px,.8fr) minmax(320px,1.2fr) auto}
|
||||||
.server-file-directory-tabs{display:flex;gap:6px;overflow:auto;min-width:0;padding-bottom:2px}
|
.server-file-directory-tabs{display:flex;gap:6px;overflow:auto;min-width:0;padding-bottom:2px}
|
||||||
.server-file-search{display:flex;align-items:center;gap:7px;min-width:0;min-height:38px;padding:0 8px;border:1px solid var(--line);border-radius:8px;background:var(--control-surface);box-shadow:inset 0 1px 0 var(--crystal-rim)}
|
.server-file-search{display:flex;align-items:center;gap:7px;min-width:0;min-height:38px;padding:0 8px;border:1px solid var(--line);border-radius:8px;background:var(--control-surface);box-shadow:inset 0 1px 0 var(--crystal-rim)}
|
||||||
.server-file-search input[type=search]{min-width:120px;flex:1 1 auto;border:0;background:transparent;color:var(--ink);font:inherit;outline:0}
|
.server-file-search input[type=search]{min-width:120px;flex:1 1 auto;border:0;background:transparent;color:var(--ink);font:inherit;outline:0}
|
||||||
@@ -464,11 +464,11 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
|
|||||||
.server-file-upload-control{min-height:36px;display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid var(--line-strong);border-radius:8px;padding:0 12px;background:var(--control-surface);color:var(--ink-soft);cursor:pointer;box-shadow:inset 0 1px 0 var(--crystal-rim),0 8px 18px var(--glass-shadow);font-weight:700;white-space:nowrap}
|
.server-file-upload-control{min-height:36px;display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid var(--line-strong);border-radius:8px;padding:0 12px;background:var(--control-surface);color:var(--ink-soft);cursor:pointer;box-shadow:inset 0 1px 0 var(--crystal-rim),0 8px 18px var(--glass-shadow);font-weight:700;white-space:nowrap}
|
||||||
.server-file-upload-control input{display:none}.server-file-upload-control:hover,.server-file-upload-control:focus-within{border-color:var(--accent);color:var(--ink);box-shadow:inset 0 1px 0 var(--crystal-rim),0 0 0 2px var(--accent-soft),0 12px 26px var(--glass-shadow)}
|
.server-file-upload-control input{display:none}.server-file-upload-control:hover,.server-file-upload-control:focus-within{border-color:var(--accent);color:var(--ink);box-shadow:inset 0 1px 0 var(--crystal-rim),0 0 0 2px var(--accent-soft),0 12px 26px var(--glass-shadow)}
|
||||||
.server-file-upload-disabled{opacity:.55;cursor:not-allowed}
|
.server-file-upload-disabled{opacity:.55;cursor:not-allowed}
|
||||||
.server-file-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(280px,360px);gap:12px;align-items:start;min-width:0}
|
.server-file-table-wrap{max-height:min(68vh,760px)}.server-file-table{min-width:760px}.server-file-table th:last-child,.server-file-table td:last-child{width:190px}.server-file-directory-row{background:color-mix(in srgb,var(--accent-soft) 54%,transparent)}
|
||||||
.server-file-table-wrap{max-height:560px}.server-file-table{min-width:860px}.server-file-table td:first-child,.server-file-table th:first-child{width:44px}.server-file-directory-row{background:color-mix(in srgb,var(--accent-soft) 54%,transparent)}
|
|
||||||
.server-file-name-button{display:inline-flex;align-items:center;gap:8px;color:var(--ink);font-weight:850}.server-file-name-button svg{color:var(--accent-deep)}
|
.server-file-name-button{display:inline-flex;align-items:center;gap:8px;color:var(--ink);font-weight:850}.server-file-name-button svg{color:var(--accent-deep)}
|
||||||
.server-file-editor{display:grid;gap:10px;padding:12px;border:1px solid var(--line);border-radius:8px;background:var(--glass-wash),var(--glass-tint),var(--surface);box-shadow:inset 0 1px 0 var(--crystal-rim),0 14px 30px var(--glass-shadow);min-width:0}
|
.server-file-editor-overlay{position:fixed;inset:0;z-index:70;display:grid;place-items:center;padding:24px;background:rgba(0,0,0,.42);backdrop-filter:blur(10px)}
|
||||||
.server-file-editor-field{display:grid;gap:6px;color:var(--ink-soft);font-size:13px;font-weight:800}.server-file-editor-field textarea{min-height:320px;width:100%;border:1px solid var(--line-strong);border-radius:8px;padding:10px;background:var(--surface-solid);color:var(--ink);font:13px/1.45 var(--font-mono);resize:vertical}.server-file-editor-field textarea:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)}
|
.server-file-editor{display:grid;gap:10px;width:min(960px,calc(100vw - 48px));max-height:calc(100dvh - 48px);overflow:auto;padding:12px;border:1px solid var(--line);border-radius:8px;background:var(--glass-wash),var(--glass-tint),var(--surface);box-shadow:inset 0 1px 0 var(--crystal-rim),0 24px 70px var(--glass-shadow);min-width:0}
|
||||||
|
.server-file-editor-field{display:grid;gap:6px;color:var(--ink-soft);font-size:13px;font-weight:800}.server-file-editor-field textarea{min-height:320px;height:min(62vh,560px);width:100%;border:1px solid var(--line-strong);border-radius:8px;padding:10px;background:var(--surface-solid);color:var(--ink);font:13px/1.45 var(--font-mono);resize:vertical}.server-file-editor-field textarea:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)}
|
||||||
.server-file-editor-actions .primary-command{width:auto}
|
.server-file-editor-actions .primary-command{width:auto}
|
||||||
.server-card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr));gap:16px}
|
.server-card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr));gap:16px}
|
||||||
.server-card{display:grid;gap:12px;padding:16px;border:1px solid var(--line);border-radius:8px;background:var(--frosted-surface),var(--glass-tint),var(--surface);backdrop-filter:blur(22px) saturate(1.28);text-align:left;transition:transform 120ms ease,border-color 120ms ease;box-shadow:var(--jelly-inset),inset 0 0 0 1px var(--diamond-line),0 18px 42px var(--glass-shadow),0 0 28px rgba(255,255,255,.2);position:relative;overflow:hidden;min-width:0}
|
.server-card{display:grid;gap:12px;padding:16px;border:1px solid var(--line);border-radius:8px;background:var(--frosted-surface),var(--glass-tint),var(--surface);backdrop-filter:blur(22px) saturate(1.28);text-align:left;transition:transform 120ms ease,border-color 120ms ease;box-shadow:var(--jelly-inset),inset 0 0 0 1px var(--diamond-line),0 18px 42px var(--glass-shadow),0 0 28px rgba(255,255,255,.2);position:relative;overflow:hidden;min-width:0}
|
||||||
@@ -726,7 +726,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
|
|||||||
.section-tabs{overflow-x:auto;flex-wrap:nowrap;padding-bottom:4px}
|
.section-tabs{overflow-x:auto;flex-wrap:nowrap;padding-bottom:4px}
|
||||||
.server-toolbar{align-items:stretch}
|
.server-toolbar{align-items:stretch}
|
||||||
.server-toolbar input[type=search],.server-toolbar select{flex:1 1 100%;min-width:0}
|
.server-toolbar input[type=search],.server-toolbar select{flex:1 1 100%;min-width:0}
|
||||||
.server-file-toolbar,.server-file-layout{grid-template-columns:1fr}.server-file-search,.server-file-actions,.server-file-pathbar{align-items:stretch;flex-wrap:wrap}.server-file-search input[type=search]{min-width:0}.server-file-actions{justify-content:stretch}.server-file-actions>*{flex:1 1 auto}
|
.server-file-toolbar{grid-template-columns:1fr}.server-file-search,.server-file-actions,.server-file-pathbar{align-items:stretch;flex-wrap:wrap}.server-file-search input[type=search]{min-width:0}.server-file-actions{justify-content:stretch}.server-file-actions>*{flex:1 1 auto}
|
||||||
.plugin-control-row,.server-card-head,.server-detail-title-row{grid-template-columns:1fr;align-items:stretch}
|
.plugin-control-row,.server-card-head,.server-detail-title-row{grid-template-columns:1fr;align-items:stretch}
|
||||||
.server-card-head,.server-detail-title-row{display:grid}
|
.server-card-head,.server-detail-title-row{display:grid}
|
||||||
.server-detail-title-row .action-strip{align-items:stretch}
|
.server-detail-title-row .action-strip{align-items:stretch}
|
||||||
|
|||||||
Reference in New Issue
Block a user