From 44d069da2b90b1cc59e74e38ee6f79aa85b54635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:14:45 +0800 Subject: [PATCH] Add files via upload --- internal/handler/chat_uploads.go | 388 ++++++++++++++++++++++--- internal/handler/openapi.go | 57 +++- internal/handler/rbac_boundary_test.go | 127 ++++++++ 3 files changed, 519 insertions(+), 53 deletions(-) diff --git a/internal/handler/chat_uploads.go b/internal/handler/chat_uploads.go index dea41625..6edabcf1 100644 --- a/internal/handler/chat_uploads.go +++ b/internal/handler/chat_uploads.go @@ -27,11 +27,14 @@ import ( const ( chatUploadsRootDirName = "chat_uploads" reductionRootDirName = "tmp/reduction" + workspaceRootDirName = "tmp/workspace" artifactsRootDirName = "data/conversation_artifacts" reductionVirtualPrefix = "__reduction__/" + workspaceVirtualPrefix = "__workspace__/" artifactVirtualPrefix = "__conversation_artifact__/" chatUploadSourceUpload = "upload" chatUploadSourceReduction = "reduction" + chatUploadSourceWorkspace = "workspace" chatUploadSourceConversation = "conversation_artifact" maxChatUploadEditBytes = 2 * 1024 * 1024 // 文本编辑上限 ) @@ -104,6 +107,11 @@ func (h *ChatUploadsHandler) reductionPathAllowed(c *gin.Context, scope, id stri func (h *ChatUploadsHandler) reductionVirtualPathAllowed(c *gin.Context, relativePath string) bool { rel := strings.TrimPrefix(strings.TrimSpace(relativePath), reductionVirtualPrefix) + rel = strings.Trim(filepath.ToSlash(filepath.Clean(filepath.FromSlash(rel))), "/") + if rel == "projects" || rel == "conversations" { + _, ok := security.CurrentSession(c) + return ok + } parts := strings.Split(filepath.ToSlash(rel), "/") if len(parts) < 2 { return false @@ -111,6 +119,24 @@ func (h *ChatUploadsHandler) reductionVirtualPathAllowed(c *gin.Context, relativ return h.reductionPathAllowed(c, parts[0], parts[1]) } +func (h *ChatUploadsHandler) workspacePathAllowed(c *gin.Context, scope, id string) bool { + return h.reductionPathAllowed(c, scope, id) +} + +func (h *ChatUploadsHandler) workspaceVirtualPathAllowed(c *gin.Context, relativePath string) bool { + rel := strings.TrimPrefix(strings.TrimSpace(relativePath), workspaceVirtualPrefix) + rel = strings.Trim(filepath.ToSlash(filepath.Clean(filepath.FromSlash(rel))), "/") + if rel == "projects" || rel == "conversations" { + _, ok := security.CurrentSession(c) + return ok + } + parts := strings.Split(filepath.ToSlash(rel), "/") + if len(parts) < 2 { + return false + } + return h.workspacePathAllowed(c, parts[0], parts[1]) +} + func (h *ChatUploadsHandler) conversationArtifactPathAllowed(c *gin.Context, conversationID string) bool { session, ok := security.CurrentSession(c) if !ok || h.db == nil { @@ -163,6 +189,26 @@ func (h *ChatUploadsHandler) absReductionRoot() (string, error) { return filepath.Abs(filepath.Join(cwd, reductionRootDirName)) } +func (h *ChatUploadsHandler) absWorkspaceRoot() (string, error) { + if h.db != nil { + if base := strings.TrimSpace(h.db.EinoWorkspaceBaseDir()); base != "" { + if filepath.IsAbs(base) { + return filepath.Abs(base) + } + cwd, err := os.Getwd() + if err != nil { + return "", err + } + return filepath.Abs(filepath.Join(cwd, base)) + } + } + cwd, err := os.Getwd() + if err != nil { + return "", err + } + return filepath.Abs(filepath.Join(cwd, workspaceRootDirName)) +} + func (h *ChatUploadsHandler) absConversationArtifactsRoot() (string, error) { if h.db != nil { if base := strings.TrimSpace(h.db.ConversationArtifactsBaseDir()); base != "" { @@ -211,15 +257,17 @@ func (h *ChatUploadsHandler) resolveUnderChatUploads(relativePath string) (abs s // ChatUploadFileItem 列表项 type ChatUploadFileItem struct { - RelativePath string `json:"relativePath"` - AbsolutePath string `json:"absolutePath"` // 服务器上的绝对路径,便于在对话中引用(与附件落盘路径一致) - Name string `json:"name"` - Source string `json:"source,omitempty"` - Size int64 `json:"size"` - ModifiedUnix int64 `json:"modifiedUnix"` - Date string `json:"date"` - ConversationID string `json:"conversationId"` - ProjectID string `json:"projectId,omitempty"` + RelativePath string `json:"relativePath"` + AbsolutePath string `json:"absolutePath"` // 服务器上的绝对路径,便于在对话中引用(与附件落盘路径一致) + Name string `json:"name"` + Source string `json:"source,omitempty"` + Size int64 `json:"size"` + ModifiedUnix int64 `json:"modifiedUnix"` + Date string `json:"date"` + ConversationID string `json:"conversationId"` + ConversationTitle string `json:"conversationTitle,omitempty"` + ProjectID string `json:"projectId,omitempty"` + ProjectName string `json:"projectName,omitempty"` // SubPath 为日期、会话目录之下的子路径(不含文件名),如 date/conv/a/b/file 则为 "a/b";无嵌套则为 ""。 SubPath string `json:"subPath"` } @@ -240,6 +288,38 @@ func (h *ChatUploadsHandler) conversationProjectID(conversationID string, cache return projectID } +func (h *ChatUploadsHandler) conversationTitle(conversationID string, cache map[string]string) string { + conversationID = strings.TrimSpace(conversationID) + if conversationID == "" || conversationID == "_manual" || conversationID == "_new" || h.db == nil { + return "" + } + if v, ok := cache[conversationID]; ok { + return v + } + title, err := h.db.GetConversationTitle(conversationID) + if err != nil { + title = "" + } + cache[conversationID] = title + return title +} + +func (h *ChatUploadsHandler) projectName(projectID string, cache map[string]string) string { + projectID = strings.TrimSpace(projectID) + if projectID == "" || h.db == nil { + return "" + } + if v, ok := cache[projectID]; ok { + return v + } + name, err := h.db.GetProjectName(projectID) + if err != nil { + name = "" + } + cache[projectID] = name + return name +} + func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, projectFilter string) ([]ChatUploadFileItem, []string, error) { root, err := h.absRoot() if err != nil { @@ -252,6 +332,8 @@ func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, pr var files []ChatUploadFileItem var folders []string projectCache := make(map[string]string) + projectNameCache := make(map[string]string) + conversationTitleCache := make(map[string]string) err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr @@ -293,16 +375,18 @@ func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, pr } absPath, _ := filepath.Abs(path) files = append(files, ChatUploadFileItem{ - RelativePath: relSlash, - AbsolutePath: absPath, - Name: d.Name(), - Source: chatUploadSourceUpload, - Size: info.Size(), - ModifiedUnix: info.ModTime().Unix(), - Date: dateStr, - ConversationID: convID, - ProjectID: projectID, - SubPath: subPath, + RelativePath: relSlash, + AbsolutePath: absPath, + Name: d.Name(), + Source: chatUploadSourceUpload, + Size: info.Size(), + ModifiedUnix: info.ModTime().Unix(), + Date: dateStr, + ConversationID: convID, + ConversationTitle: h.conversationTitle(convID, conversationTitleCache), + ProjectID: projectID, + ProjectName: h.projectName(projectID, projectNameCache), + SubPath: subPath, }) return nil }) @@ -354,6 +438,12 @@ func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, pr } else if len(reductionFiles) > 0 { files = append(files, reductionFiles...) } + workspaceFiles, err := h.collectWorkspaceFiles(c, conversationFilter, projectFilter) + if err != nil { + h.logger.Warn("列举 workspace 产物失败", zap.Error(err)) + } else if len(workspaceFiles) > 0 { + files = append(files, workspaceFiles...) + } artifactFiles, err := h.collectConversationArtifactFiles(c, conversationFilter, projectFilter) if err != nil { h.logger.Warn("列举 conversation_artifacts 产物失败", zap.Error(err)) @@ -375,6 +465,8 @@ func (h *ChatUploadsHandler) collectReductionFiles(c *gin.Context, conversationF return nil, nil } projectCache := make(map[string]string) + projectNameCache := make(map[string]string) + conversationTitleCache := make(map[string]string) files := make([]ChatUploadFileItem, 0) for _, scope := range []string{"conversations", "projects"} { scopeRoot := filepath.Join(root, scope) @@ -403,7 +495,10 @@ func (h *ChatUploadsHandler) collectReductionFiles(c *gin.Context, conversationF projectID = ownerID } if conversationFilter != "" && convID != conversationFilter { - return nil + if scope != "projects" || h.conversationProjectID(conversationFilter, projectCache) != projectID { + return nil + } + convID = conversationFilter } if projectFilter != "" && projectID != projectFilter { return nil @@ -418,16 +513,90 @@ func (h *ChatUploadsHandler) collectReductionFiles(c *gin.Context, conversationF } abs, _ := filepath.Abs(path) files = append(files, ChatUploadFileItem{ - RelativePath: reductionVirtualPrefix + relSlash, - AbsolutePath: abs, - Name: name, - Source: chatUploadSourceReduction, - Size: info.Size(), - ModifiedUnix: info.ModTime().Unix(), - Date: info.ModTime().Format("2006-01-02"), - ConversationID: convID, - ProjectID: projectID, - SubPath: strings.Join(parts[2:len(parts)-1], "/"), + RelativePath: reductionVirtualPrefix + relSlash, + AbsolutePath: abs, + Name: name, + Source: chatUploadSourceReduction, + Size: info.Size(), + ModifiedUnix: info.ModTime().Unix(), + Date: info.ModTime().Format("2006-01-02"), + ConversationID: convID, + ConversationTitle: h.conversationTitle(convID, conversationTitleCache), + ProjectID: projectID, + ProjectName: h.projectName(projectID, projectNameCache), + SubPath: strings.Join(parts[2:len(parts)-1], "/"), + }) + return nil + }) + } + return files, nil +} + +func (h *ChatUploadsHandler) collectWorkspaceFiles(c *gin.Context, conversationFilter, projectFilter string) ([]ChatUploadFileItem, error) { + root, err := h.absWorkspaceRoot() + if err != nil { + return nil, err + } + if st, err := os.Stat(root); err != nil || !st.IsDir() { + return nil, nil + } + projectCache := make(map[string]string) + projectNameCache := make(map[string]string) + conversationTitleCache := make(map[string]string) + files := make([]ChatUploadFileItem, 0) + for _, scope := range []string{"conversations", "projects"} { + scopeRoot := filepath.Join(root, scope) + _ = filepath.WalkDir(scopeRoot, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil || d == nil || d.IsDir() { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return nil + } + relSlash := filepath.ToSlash(rel) + parts := strings.Split(relSlash, "/") + if len(parts) < 3 { + return nil + } + ownerID := parts[1] + if !h.workspacePathAllowed(c, scope, ownerID) { + return nil + } + var convID, projectID string + if scope == "conversations" { + convID = ownerID + projectID = h.conversationProjectID(convID, projectCache) + } else { + projectID = ownerID + } + if conversationFilter != "" && convID != conversationFilter { + if scope != "projects" || h.conversationProjectID(conversationFilter, projectCache) != projectID { + return nil + } + convID = conversationFilter + } + if projectFilter != "" && projectID != projectFilter { + return nil + } + info, err := d.Info() + if err != nil { + return nil + } + abs, _ := filepath.Abs(path) + files = append(files, ChatUploadFileItem{ + RelativePath: workspaceVirtualPrefix + relSlash, + AbsolutePath: abs, + Name: d.Name(), + Source: chatUploadSourceWorkspace, + Size: info.Size(), + ModifiedUnix: info.ModTime().Unix(), + Date: info.ModTime().Format("2006-01-02"), + ConversationID: convID, + ConversationTitle: h.conversationTitle(convID, conversationTitleCache), + ProjectID: projectID, + ProjectName: h.projectName(projectID, projectNameCache), + SubPath: strings.Join(parts[2:len(parts)-1], "/"), }) return nil }) @@ -444,6 +613,8 @@ func (h *ChatUploadsHandler) collectConversationArtifactFiles(c *gin.Context, co return nil, nil } projectCache := make(map[string]string) + projectNameCache := make(map[string]string) + conversationTitleCache := make(map[string]string) files := make([]ChatUploadFileItem, 0) _ = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { if walkErr != nil || d == nil || d.IsDir() { @@ -479,16 +650,18 @@ func (h *ChatUploadsHandler) collectConversationArtifactFiles(c *gin.Context, co } abs, _ := filepath.Abs(path) files = append(files, ChatUploadFileItem{ - RelativePath: artifactVirtualPrefix + relSlash, - AbsolutePath: abs, - Name: name, - Source: chatUploadSourceConversation, - Size: info.Size(), - ModifiedUnix: info.ModTime().Unix(), - Date: info.ModTime().Format("2006-01-02"), - ConversationID: convID, - ProjectID: projectID, - SubPath: strings.Join(parts[1:len(parts)-1], "/"), + RelativePath: artifactVirtualPrefix + relSlash, + AbsolutePath: abs, + Name: name, + Source: chatUploadSourceConversation, + Size: info.Size(), + ModifiedUnix: info.ModTime().Unix(), + Date: info.ModTime().Format("2006-01-02"), + ConversationID: convID, + ConversationTitle: h.conversationTitle(convID, conversationTitleCache), + ProjectID: projectID, + ProjectName: h.projectName(projectID, projectNameCache), + SubPath: strings.Join(parts[1:len(parts)-1], "/"), }) return nil }) @@ -516,6 +689,27 @@ func (h *ChatUploadsHandler) resolveReductionVirtualPath(relativePath string) (s return full, nil } +func (h *ChatUploadsHandler) resolveWorkspaceVirtualPath(relativePath string) (string, error) { + rel := strings.TrimPrefix(strings.TrimSpace(relativePath), workspaceVirtualPrefix) + rel = filepath.Clean(filepath.FromSlash(rel)) + if rel == "." || strings.HasPrefix(rel, "..") { + return "", fmt.Errorf("invalid path") + } + root, err := h.absWorkspaceRoot() + if err != nil { + return "", err + } + full, err := filepath.Abs(filepath.Join(root, rel)) + if err != nil { + return "", err + } + rootAbs, _ := filepath.Abs(root) + if full != rootAbs && !strings.HasPrefix(full, rootAbs+string(filepath.Separator)) { + return "", fmt.Errorf("path escapes workspace root") + } + return full, nil +} + func (h *ChatUploadsHandler) resolveConversationArtifactVirtualPath(relativePath string) (string, error) { rel := strings.TrimPrefix(strings.TrimSpace(relativePath), artifactVirtualPrefix) rel = filepath.Clean(filepath.FromSlash(rel)) @@ -539,8 +733,10 @@ func (h *ChatUploadsHandler) resolveConversationArtifactVirtualPath(relativePath func chatUploadItemIsInternal(item ChatUploadFileItem) bool { return item.Source == chatUploadSourceReduction || + item.Source == chatUploadSourceWorkspace || item.Source == chatUploadSourceConversation || strings.HasPrefix(item.RelativePath, reductionVirtualPrefix) || + strings.HasPrefix(item.RelativePath, workspaceVirtualPrefix) || strings.HasPrefix(item.RelativePath, artifactVirtualPrefix) } @@ -548,6 +744,8 @@ func (h *ChatUploadsHandler) resolveListedFilePath(item ChatUploadFileItem) (str switch { case item.Source == chatUploadSourceReduction || strings.HasPrefix(item.RelativePath, reductionVirtualPrefix): return h.resolveReductionVirtualPath(item.RelativePath) + case item.Source == chatUploadSourceWorkspace || strings.HasPrefix(item.RelativePath, workspaceVirtualPrefix): + return h.resolveWorkspaceVirtualPath(item.RelativePath) case item.Source == chatUploadSourceConversation || strings.HasPrefix(item.RelativePath, artifactVirtualPrefix): return h.resolveConversationArtifactVirtualPath(item.RelativePath) default: @@ -705,7 +903,7 @@ func (h *ChatUploadsHandler) Export(c *gin.Context) { "fileCount": len(files), "files": files, "layout": "chat uploads are stored under conversations//; internal outputs are stored under internal//", - "sourceDirectory": []string{chatUploadsRootDirName, reductionRootDirName, artifactsRootDirName}, + "sourceDirectory": []string{chatUploadsRootDirName, reductionRootDirName, workspaceRootDirName, artifactsRootDirName}, } manifestBytes, _ := json.MarshalIndent(manifest, "", " ") mw, err := zw.Create("manifest.json") @@ -732,6 +930,9 @@ func (h *ChatUploadsHandler) Export(c *gin.Context) { if filepath.Ext(zipName) == "" { zipName += ".txt" } + } else if item.Source == chatUploadSourceWorkspace || strings.HasPrefix(item.RelativePath, workspaceVirtualPrefix) { + rel := strings.TrimPrefix(item.RelativePath, workspaceVirtualPrefix) + zipName = filepath.ToSlash(filepath.Join("internal", "workspace", rel)) } else if item.Source == chatUploadSourceConversation || strings.HasPrefix(item.RelativePath, artifactVirtualPrefix) { rel := strings.TrimPrefix(item.RelativePath, artifactVirtualPrefix) zipName = filepath.ToSlash(filepath.Join("internal", "conversation_artifacts", rel)) @@ -800,6 +1001,24 @@ func (h *ChatUploadsHandler) Download(c *gin.Context) { c.FileAttachment(abs, name) return } + if strings.HasPrefix(strings.TrimSpace(p), workspaceVirtualPrefix) { + if !h.workspaceVirtualPathAllowed(c, p) { + c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) + return + } + abs, err := h.resolveWorkspaceVirtualPath(p) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + st, err := os.Stat(abs) + if err != nil || st.IsDir() { + c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) + return + } + c.FileAttachment(abs, filepath.Base(abs)) + return + } if strings.HasPrefix(strings.TrimSpace(p), artifactVirtualPrefix) { if !h.conversationArtifactVirtualPathAllowed(c, p) { c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) @@ -839,6 +1058,93 @@ func (h *ChatUploadsHandler) Download(c *gin.Context) { c.FileAttachment(abs, filepath.Base(abs)) } +// ResolvePath GET /api/chat-uploads/path?path=...&kind=file|directory +func (h *ChatUploadsHandler) ResolvePath(c *gin.Context) { + p := strings.TrimSpace(c.Query("path")) + kind := strings.TrimSpace(c.Query("kind")) + if kind == "" { + kind = "file" + } + var abs string + var err error + switch { + case strings.HasPrefix(p, reductionVirtualPrefix): + if strings.Trim(strings.TrimPrefix(p, reductionVirtualPrefix), "/") == "" { + if _, ok := security.CurrentSession(c); !ok { + c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) + return + } + abs, err = h.absReductionRoot() + break + } + if !h.reductionVirtualPathAllowed(c, p) { + c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) + return + } + abs, err = h.resolveReductionVirtualPath(p) + case strings.HasPrefix(p, workspaceVirtualPrefix): + if strings.Trim(strings.TrimPrefix(p, workspaceVirtualPrefix), "/") == "" { + if _, ok := security.CurrentSession(c); !ok { + c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) + return + } + abs, err = h.absWorkspaceRoot() + break + } + if !h.workspaceVirtualPathAllowed(c, p) { + c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) + return + } + abs, err = h.resolveWorkspaceVirtualPath(p) + case strings.HasPrefix(p, artifactVirtualPrefix): + if strings.Trim(strings.TrimPrefix(p, artifactVirtualPrefix), "/") == "" { + if _, ok := security.CurrentSession(c); !ok { + c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) + return + } + abs, err = h.absConversationArtifactsRoot() + break + } + if !h.conversationArtifactVirtualPathAllowed(c, p) { + c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) + return + } + abs, err = h.resolveConversationArtifactVirtualPath(p) + default: + if p == "" || p == "." { + if _, ok := security.CurrentSession(c); !ok { + c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) + return + } + abs, err = h.absRoot() + break + } + if !h.pathAllowed(c, p) { + c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) + return + } + abs, err = h.resolveUnderChatUploads(p) + } + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + st, err := os.Stat(abs) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "path not found"}) + return + } + if kind == "directory" && !st.IsDir() { + c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"}) + return + } + if kind != "directory" && st.IsDir() { + c.JSON(http.StatusBadRequest, gin.H{"error": "not a file"}) + return + } + c.JSON(http.StatusOK, gin.H{"absolutePath": abs, "isDir": st.IsDir()}) +} + type chatUploadPathBody struct { Path string `json:"path"` } diff --git a/internal/handler/openapi.go b/internal/handler/openapi.go index a1933000..2e317393 100644 --- a/internal/handler/openapi.go +++ b/internal/handler/openapi.go @@ -5803,7 +5803,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { "parameters": []map[string]interface{}{ {"name": "conversation", "in": "query", "required": false, "description": "按对话ID过滤", "schema": map[string]interface{}{"type": "string"}}, {"name": "project", "in": "query", "required": false, "description": "按项目ID过滤", "schema": map[string]interface{}{"type": "string"}}, - {"name": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "conversation_artifact"}}}, + {"name": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/workspace/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "workspace", "conversation_artifact"}}}, {"name": "search", "in": "query", "required": false, "description": "按文件名或子路径搜索", "schema": map[string]interface{}{"type": "string"}}, {"name": "page", "in": "query", "required": false, "description": "页码,从1开始", "schema": map[string]interface{}{"type": "integer", "default": 1}}, {"name": "pageSize", "in": "query", "required": false, "description": "每页数量,传 all 返回全部", "schema": map[string]interface{}{"oneOf": []map[string]interface{}{{"type": "integer"}, {"type": "string", "enum": []string{"all"}}}}}, @@ -5821,16 +5821,18 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { "items": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "relativePath": map[string]interface{}{"type": "string"}, - "absolutePath": map[string]interface{}{"type": "string"}, - "name": map[string]interface{}{"type": "string"}, - "size": map[string]interface{}{"type": "integer"}, - "modifiedUnix": map[string]interface{}{"type": "integer"}, - "date": map[string]interface{}{"type": "string"}, - "conversationId": map[string]interface{}{"type": "string"}, - "projectId": map[string]interface{}{"type": "string"}, - "subPath": map[string]interface{}{"type": "string"}, - "source": map[string]interface{}{"type": "string", "description": "upload/reduction/conversation_artifact"}, + "relativePath": map[string]interface{}{"type": "string"}, + "absolutePath": map[string]interface{}{"type": "string"}, + "name": map[string]interface{}{"type": "string"}, + "size": map[string]interface{}{"type": "integer"}, + "modifiedUnix": map[string]interface{}{"type": "integer"}, + "date": map[string]interface{}{"type": "string"}, + "conversationId": map[string]interface{}{"type": "string"}, + "conversationTitle": map[string]interface{}{"type": "string"}, + "projectId": map[string]interface{}{"type": "string"}, + "projectName": map[string]interface{}{"type": "string"}, + "subPath": map[string]interface{}{"type": "string"}, + "source": map[string]interface{}{"type": "string", "description": "upload/reduction/workspace/conversation_artifact"}, }, }, }, @@ -5923,7 +5925,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { "parameters": []map[string]interface{}{ {"name": "conversation", "in": "query", "required": false, "description": "按对话ID过滤", "schema": map[string]interface{}{"type": "string"}}, {"name": "project", "in": "query", "required": false, "description": "按项目ID过滤", "schema": map[string]interface{}{"type": "string"}}, - {"name": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "conversation_artifact"}}}, + {"name": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/workspace/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "workspace", "conversation_artifact"}}}, {"name": "search", "in": "query", "required": false, "description": "按文件名或子路径搜索", "schema": map[string]interface{}{"type": "string"}}, }, "responses": map[string]interface{}{ @@ -5962,6 +5964,37 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { }, }, }, + "/api/chat-uploads/path": map[string]interface{}{ + "get": map[string]interface{}{ + "tags": []string{"对话附件"}, + "summary": "解析附件路径", + "description": "将文件管理中的相对路径或内部虚拟路径解析为服务器绝对路径,用于复制文件/目录路径。", + "operationId": "resolveChatUploadPath", + "parameters": []map[string]interface{}{ + {"name": "path", "in": "query", "required": true, "description": "相对路径或虚拟路径(如 __workspace__/projects//csv)", "schema": map[string]interface{}{"type": "string"}}, + {"name": "kind", "in": "query", "required": false, "description": "路径类型:file/directory,默认 file", "schema": map[string]interface{}{"type": "string", "enum": []string{"file", "directory"}}}, + }, + "responses": map[string]interface{}{ + "200": map[string]interface{}{ + "description": "解析成功", + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "absolutePath": map[string]interface{}{"type": "string"}, + "isDir": map[string]interface{}{"type": "boolean"}, + }, + }, + }, + }, + }, + "401": map[string]interface{}{"description": "未授权"}, + "403": map[string]interface{}{"description": "无权访问"}, + "404": map[string]interface{}{"description": "路径不存在"}, + }, + }, + }, "/api/chat-uploads/content": map[string]interface{}{ "get": map[string]interface{}{ "tags": []string{"对话附件"}, diff --git a/internal/handler/rbac_boundary_test.go b/internal/handler/rbac_boundary_test.go index c353db2c..dde5274f 100644 --- a/internal/handler/rbac_boundary_test.go +++ b/internal/handler/rbac_boundary_test.go @@ -6,6 +6,8 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "net/url" + "os" "path/filepath" "testing" "time" @@ -146,6 +148,131 @@ func TestChatUploadPathAuthorizationFollowsConversationAccess(t *testing.T) { } } +func TestChatUploadsListIncludesAuthorizedProjectWorkspaceFiles(t *testing.T) { + db, user := setupConversationRBACTest(t) + fsBase := t.TempDir() + workspaceBase := filepath.Join(fsBase, "workspace") + reductionBase := filepath.Join(fsBase, "reduction") + db.SetEinoConversationDirs("", "", reductionBase, workspaceBase) + allowedProject, _ := db.CreateProject(&database.Project{Name: "allowed"}) + hiddenProject, _ := db.CreateProject(&database.Project{Name: "hidden"}) + conversation, _ := db.CreateConversation("project conversation", database.ConversationCreateMeta{ProjectID: allowedProject.ID}) + if err := db.AssignResourceToUser(user.ID, "project", allowedProject.ID); err != nil { + t.Fatal(err) + } + allowedFile := filepath.Join(workspaceBase, "projects", allowedProject.ID, "csv", "assets.csv") + hiddenFile := filepath.Join(workspaceBase, "projects", hiddenProject.ID, "csv", "secret.csv") + for _, path := range []string{allowedFile, hiddenFile} { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("name\nexample\n"), 0o644); err != nil { + t.Fatal(err) + } + } + + h := NewChatUploadsHandler(zap.NewNop(), db) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/chat-uploads?source=workspace&pageSize=all&conversation="+conversation.ID, nil) + c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned}) + h.List(c) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String()) + } + var response struct { + Files []ChatUploadFileItem `json:"files"` + Total int `json:"total"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.Total != 1 || len(response.Files) != 1 { + t.Fatalf("files = %#v, total = %d, want only authorized workspace file", response.Files, response.Total) + } + got := response.Files[0] + if got.Source != chatUploadSourceWorkspace || got.Name != "assets.csv" || got.ProjectID != allowedProject.ID { + t.Fatalf("workspace file = %#v", got) + } + if got.ProjectName != allowedProject.Name { + t.Fatalf("projectName = %q, want %q", got.ProjectName, allowedProject.Name) + } + if got.ConversationID != conversation.ID { + t.Fatalf("conversationId = %q, want %q", got.ConversationID, conversation.ID) + } + if got.ConversationTitle != conversation.Title { + t.Fatalf("conversationTitle = %q, want %q", got.ConversationTitle, conversation.Title) + } + if got.AbsolutePath != allowedFile { + t.Fatalf("absolutePath = %q, want %q", got.AbsolutePath, allowedFile) + } + + w = httptest.NewRecorder() + c, _ = gin.CreateTestContext(w) + resolveURL := "/api/chat-uploads/path?kind=directory&path=__workspace__%2Fprojects%2F" + allowedProject.ID + c.Request = httptest.NewRequest(http.MethodGet, resolveURL, nil) + c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned}) + h.ResolvePath(c) + if w.Code != http.StatusOK { + t.Fatalf("resolve status = %d, want 200: %s", w.Code, w.Body.String()) + } + var resolved struct { + AbsolutePath string `json:"absolutePath"` + IsDir bool `json:"isDir"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resolved); err != nil { + t.Fatal(err) + } + wantDir := filepath.Join(workspaceBase, "projects", allowedProject.ID) + if !resolved.IsDir || resolved.AbsolutePath != wantDir { + t.Fatalf("resolved = %#v, want dir %q", resolved, wantDir) + } + + w = httptest.NewRecorder() + c, _ = gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/chat-uploads/path?kind=directory&path=__workspace__%2Fprojects", nil) + c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned}) + h.ResolvePath(c) + if w.Code != http.StatusOK { + t.Fatalf("resolve projects container status = %d, want 200: %s", w.Code, w.Body.String()) + } + if err := json.Unmarshal(w.Body.Bytes(), &resolved); err != nil { + t.Fatal(err) + } + wantContainer := filepath.Join(workspaceBase, "projects") + if !resolved.IsDir || resolved.AbsolutePath != wantContainer { + t.Fatalf("resolved container = %#v, want dir %q", resolved, wantContainer) + } + + for _, tc := range []struct { + path string + want string + }{ + {"__workspace__/", workspaceBase}, + {"__reduction__/", reductionBase}, + {"__conversation_artifact__/", db.ConversationArtifactsBaseDir()}, + } { + if err := os.MkdirAll(tc.want, 0o755); err != nil { + t.Fatal(err) + } + w = httptest.NewRecorder() + c, _ = gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/chat-uploads/path?kind=directory&path="+url.QueryEscape(tc.path), nil) + c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned}) + h.ResolvePath(c) + if w.Code != http.StatusOK { + t.Fatalf("resolve root %q status = %d, want 200: %s", tc.path, w.Code, w.Body.String()) + } + if err := json.Unmarshal(w.Body.Bytes(), &resolved); err != nil { + t.Fatal(err) + } + wantAbs, _ := filepath.Abs(tc.want) + if !resolved.IsDir || resolved.AbsolutePath != wantAbs { + t.Fatalf("resolved root %q = %#v, want dir %q", tc.path, resolved, wantAbs) + } + } +} + func TestPrepareMultiAgentSessionRejectsForeignConversation(t *testing.T) { db, user := setupConversationRBACTest(t) hidden, _ := db.CreateConversation("hidden", database.ConversationCreateMeta{})