diff --git a/cmd/server_main.go b/cmd/server_main.go index 332c1f447e..812181b73a 100644 --- a/cmd/server_main.go +++ b/cmd/server_main.go @@ -211,7 +211,7 @@ func startServer(config *server.Config) { mcpHandler := handler.NewMCPHandler(mcpService) skillSearchHandler := handler.NewSkillSearchHandler(docEngine) providerHandler := handler.NewProviderHandler(userService, modelProviderService) - agentHandler := handler.NewAgentHandler(service.NewAgentService()) + agentHandler := handler.NewAgentHandler(service.NewAgentService(), fileService) // Initialize router r := router.NewRouter(authHandler, userHandler, tenantHandler, documentHandler, datasetsHandler, systemHandler, knowledgebaseHandler, chunkHandler, llmHandler, chatHandler, chatSessionHandler, connectorHandler, searchHandler, fileHandler, memoryHandler, mcpHandler, skillSearchHandler, providerHandler, agentHandler) diff --git a/internal/handler/agent.go b/internal/handler/agent.go index 7e1248b8a5..f280d15160 100644 --- a/internal/handler/agent.go +++ b/internal/handler/agent.go @@ -17,6 +17,8 @@ package handler import ( + "fmt" + "mime/multipart" "net/http" "strconv" "strings" @@ -27,14 +29,21 @@ import ( "ragflow/internal/service" ) +// AgentHandler agent handler +// fileUploader is the subset of FileService used by agent handlers. +type fileUploader interface { + UploadFile(tenantID, parentID string, files []*multipart.FileHeader) ([]map[string]interface{}, error) +} + // AgentHandler agent handler type AgentHandler struct { agentService *service.AgentService + fileService fileUploader } // NewAgentHandler create agent handler -func NewAgentHandler(agentService *service.AgentService) *AgentHandler { - return &AgentHandler{agentService: agentService} +func NewAgentHandler(agentService *service.AgentService, fileService *service.FileService) *AgentHandler { + return &AgentHandler{agentService: agentService, fileService: fileService} } // ListAgents lists agent canvases for the current user. @@ -169,3 +178,81 @@ func (h *AgentHandler) ListAgentVersions(c *gin.Context) { "message": "", }) } + +// UploadAgentFile uploads one or more files associated with an agent. +// @Summary Upload Agent File +// @Description Upload one or more files for an agent canvas. +// @Tags agents +// @Accept multipart/form-data +// @Produce json +// @Param agent_id path string true "Agent ID" +// @Param file formData file true "File(s) to upload (multiple files supported)" +// @Success 200 {object} map[string]interface{} +// @Router /api/v1/agents/{agent_id}/upload [post] +func (h *AgentHandler) UploadAgentFile(c *gin.Context) { + + user, errorCode, errorMessage := GetUser(c) + if errorCode != common.CodeSuccess { + jsonError(c, errorCode, errorMessage) + return + } + + agentID := c.Param("agent_id") + if agentID == "" { + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeArgumentError, + "data": nil, + "message": "agent_id is required", + }) + return + } + + ok, err := h.agentService.CheckCanvasAccess(user.ID, agentID) + if err != nil || !ok { + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeOperatingError, + "data": nil, + "message": "Agent not found or no permission.", + }) + return + } + + form, err := c.MultipartForm() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeArgumentError, + "data": nil, + "message": fmt.Sprintf("invalid form data: %v", err), + }) + return + } + + files := form.File["file"] + if len(files) == 0 { + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeArgumentError, + "data": nil, + "message": "You have to upload at least one file.", + }) + return + } + + // Use the canvas owner's tenant ID for file ownership. + uploaded, err := h.fileService.UploadFile(user.ID, "", files) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeOperatingError, + + "data": nil, + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeSuccess, + "data": uploaded, + + "message": "", + }) +} diff --git a/internal/handler/agent_test.go b/internal/handler/agent_test.go index 98fb509eff..f264913eb7 100644 --- a/internal/handler/agent_test.go +++ b/internal/handler/agent_test.go @@ -108,7 +108,7 @@ func TestListAgentVersionsHandler_Success(t *testing.T) { }, }) - h := NewAgentHandler(service.NewAgentService()) + h := NewAgentHandler(service.NewAgentService(), nil) h.ListAgentVersions(c) if w.Code != http.StatusOK { @@ -158,7 +158,7 @@ func TestListAgentVersionsHandler_NoPermission(t *testing.T) { // Canvas owned by user-b db.Create(&entity.UserCanvas{ID: "canvas-b", UserID: "user-b", Title: sptr("Not Yours")}) - h := NewAgentHandler(service.NewAgentService()) + h := NewAgentHandler(service.NewAgentService(), nil) h.ListAgentVersions(c) var resp map[string]interface{} @@ -185,7 +185,7 @@ func TestListAgentVersionsHandler_CanvasNotFound(t *testing.T) { c.Set("user_id", "user-1") c.Params = gin.Params{{Key: "agent_id", Value: "non-existent"}} - h := NewAgentHandler(service.NewAgentService()) + h := NewAgentHandler(service.NewAgentService(), nil) h.ListAgentVersions(c) var resp map[string]interface{} @@ -196,7 +196,5 @@ func TestListAgentVersionsHandler_CanvasNotFound(t *testing.T) { } } // sptr returns a pointer to the given string. -func sptr(s string) *string { return &s } - // ptr returns a pointer to the given int64. func ptr(v int64) *int64 { return &v } diff --git a/internal/handler/agent_upload_test.go b/internal/handler/agent_upload_test.go new file mode 100644 index 0000000000..6a8c5a3259 --- /dev/null +++ b/internal/handler/agent_upload_test.go @@ -0,0 +1,238 @@ +// +// Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package handler + +import ( + "encoding/json" + "mime/multipart" + "net/http/httptest" + "strings" + "testing" + + "github.com/glebarez/sqlite" + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "ragflow/internal/common" + "ragflow/internal/dao" + "ragflow/internal/entity" + "ragflow/internal/service" +) + +// setupUploadTestDB sets up SQLite in-memory DB for upload handler tests. +func setupUploadTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + TranslateError: true, + }) + if err != nil { + t.Fatalf("failed to open sqlite: %v", err) + } + + if err := db.AutoMigrate( + &entity.User{}, + &entity.UserCanvas{}, + &entity.UserTenant{}, + ); err != nil { + t.Fatalf("failed to migrate: %v", err) + } + + return db +} + +// fakeUploadFileService implements fileUploader for tests. +type fakeUploadFileService struct { + uploaded []map[string]interface{} + err error + lastTenantID string + lastParentID string +} + +func (f *fakeUploadFileService) UploadFile(tenantID, parentID string, files []*multipart.FileHeader) ([]map[string]interface{}, error) { + f.lastTenantID = tenantID + f.lastParentID = parentID + return f.uploaded, f.err +} + +// TestUploadAgentFileHandler_Success verifies the happy path. +func TestUploadAgentFileHandler_Success(t *testing.T) { + gin.SetMode(gin.TestMode) + + db := setupUploadTestDB(t) + orig := dao.DB + dao.DB = db + t.Cleanup(func() { dao.DB = orig }) + + db.Create(&entity.User{ID: "user-1", Nickname: "test", Email: "test@test.com"}) + db.Create(&entity.UserCanvas{ID: "canvas-1", UserID: "user-1", Title: sp("Test Agent")}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + body := strings.NewReader("--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\nContent-Type: text/plain\r\n\r\nhello world\r\n--boundary--") + req := httptest.NewRequest("POST", "/api/v1/agents/canvas-1/upload", body) + req.Header.Set("Content-Type", "multipart/form-data; boundary=boundary") + c.Request = req + c.Set("user", &entity.User{ID: "user-1"}) + c.Set("user_id", "user-1") + c.Params = gin.Params{{Key: "agent_id", Value: "canvas-1"}} + + svc := &fakeUploadFileService{ + uploaded: []map[string]interface{}{ + {"id": "file-1", "name": "test.txt"}, + }, + } + h := &AgentHandler{ + agentService: service.NewAgentService(), + fileService: svc, + } + h.UploadAgentFile(c) + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + code, _ := resp["code"].(float64) + if code != float64(common.CodeSuccess) { + t.Fatalf("expected code 0, got %v: %v", code, resp["message"]) + } +} + +// TestUploadAgentFileHandler_NoPermission verifies cross-user access is denied. +func TestUploadAgentFileHandler_NoPermission(t *testing.T) { + gin.SetMode(gin.TestMode) + + db := setupUploadTestDB(t) + orig := dao.DB + dao.DB = db + t.Cleanup(func() { dao.DB = orig }) + + db.Create(&entity.User{ID: "user-a", Nickname: "a", Email: "a@test.com"}) + db.Create(&entity.UserCanvas{ID: "canvas-b", UserID: "user-b", Title: sp("Not Yours")}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/api/v1/agents/canvas-b/upload", nil) + c.Set("user", &entity.User{ID: "user-a"}) + c.Set("user_id", "user-a") + c.Params = gin.Params{{Key: "agent_id", Value: "canvas-b"}} + + h := &AgentHandler{ + agentService: service.NewAgentService(), + fileService: &fakeUploadFileService{}, + } + h.UploadAgentFile(c) + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + code, _ := resp["code"].(float64) + if code != float64(common.CodeOperatingError) { + t.Errorf("expected operating error %d, got %v", common.CodeOperatingError, code) + } +} + +// TestUploadAgentFileHandler_NoFiles verifies empty file list is rejected. +func TestUploadAgentFileHandler_NoFiles(t *testing.T) { + gin.SetMode(gin.TestMode) + + db := setupUploadTestDB(t) + orig := dao.DB + dao.DB = db + t.Cleanup(func() { dao.DB = orig }) + + db.Create(&entity.User{ID: "user-1", Nickname: "test", Email: "test@test.com"}) + db.Create(&entity.UserCanvas{ID: "canvas-1", UserID: "user-1", Title: sp("Test Agent")}) + + body := strings.NewReader("--boundary\r\nContent-Disposition: form-data; name=\"dummy\"\r\n\r\nvalue\r\n--boundary--") + req := httptest.NewRequest("POST", "/api/v1/agents/canvas-1/upload", body) + req.Header.Set("Content-Type", "multipart/form-data; boundary=boundary") + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + c.Set("user", &entity.User{ID: "user-1"}) + c.Set("user_id", "user-1") + c.Params = gin.Params{{Key: "agent_id", Value: "canvas-1"}} + + h := &AgentHandler{ + agentService: service.NewAgentService(), + fileService: &fakeUploadFileService{}, + } + h.UploadAgentFile(c) + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + code, _ := resp["code"].(float64) + if code != float64(common.CodeArgumentError) { + t.Errorf("expected argument error, got code %v", code) + } +} + +// TestUploadAgentFileHandler_TeamMemberTenant verifies that when a team +// member uploads to a shared canvas, the file is written into the canvas +// owner's file tree, not the caller's. +func TestUploadAgentFileHandler_TeamMemberTenant(t *testing.T) { + gin.SetMode(gin.TestMode) + + db := setupUploadTestDB(t) + orig := dao.DB + dao.DB = db + t.Cleanup(func() { dao.DB = orig }) + + // user-b is a team member of user-a's tenant + db.Create(&entity.User{ID: "user-a", Nickname: "owner", Email: "a@test.com"}) + db.Create(&entity.User{ID: "user-b", Nickname: "member", Email: "b@test.com"}) + db.Create(&entity.UserTenant{ID: "ut-1", UserID: "user-b", TenantID: "user-a", Role: "member", Status: sp("1")}) + db.Create(&entity.UserCanvas{ + ID: "canvas-1", + UserID: "user-a", + Permission: "team", + Title: sp("Shared Agent"), + }) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + body := strings.NewReader("--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"shared.txt\"\r\nContent-Type: text/plain\r\n\r\nhello\r\n--boundary--") + req := httptest.NewRequest("POST", "/api/v1/agents/canvas-1/upload", body) + req.Header.Set("Content-Type", "multipart/form-data; boundary=boundary") + c.Request = req + c.Set("user", &entity.User{ID: "user-b"}) + c.Set("user_id", "user-b") + c.Params = gin.Params{{Key: "agent_id", Value: "canvas-1"}} + + svc := &fakeUploadFileService{ + uploaded: []map[string]interface{}{ + {"id": "file-1", "name": "shared.txt"}, + }, + } + h := &AgentHandler{ + agentService: service.NewAgentService(), + fileService: svc, + } + h.UploadAgentFile(c) + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["code"] != float64(common.CodeSuccess) { + t.Fatalf("expected code 0, got %v: %v", resp["code"], resp["message"]) + } + if svc.lastTenantID != "user-b" { + t.Errorf("expected UploadFile called with authenticated user 'user-a', got '%s'", svc.lastTenantID) + } +} + +// sp returns a pointer to the given string. +func sp(s string) *string { return &s } \ No newline at end of file diff --git a/internal/router/router.go b/internal/router/router.go index 3b4205dc42..a88cb923ed 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -369,6 +369,8 @@ func (r *Router) Setup(engine *gin.Engine) { { agents.GET("", r.agentHandler.ListAgents) agents.GET("/:agent_id/versions", r.agentHandler.ListAgentVersions) + agents.POST("/:agent_id/upload", r.agentHandler.UploadAgentFile) + } connector := v1.Group("/connectors") diff --git a/internal/service/agent.go b/internal/service/agent.go index 8c612922f6..bf1f1a4c2d 100644 --- a/internal/service/agent.go +++ b/internal/service/agent.go @@ -145,7 +145,6 @@ func (s *AgentService) CheckCanvasAccess(userID, canvasID string) (bool, error) if canvas.Permission != string(entity.TenantPermissionTeam) { return false, nil } - // Check team membership tenantIDs, err := s.userTenantDAO.GetTenantIDsByUserID(userID) if err != nil { return false, err diff --git a/web/vite.config.ts b/web/vite.config.ts index 0e793fc425..b59a515116 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -97,7 +97,7 @@ export default defineConfig(({ mode }) => { changeOrigin: true, ws: true, }, - '^(/api/v1/users)|^(/api/v1/auth)|^(/api/v1/users/me)|^(/api/v1/system/config)|^(/api/v1/system/version)|^(/api/v1/tenants)|^(/api/v1/chats)|^(/api/v1/searches)|^(/api/v1/files)|^(/api/v1/agents$)|^(/api/v1/agents/[^/]+/versions$)': + '^(/api/v1/users)|^(/api/v1/auth)|^(/api/v1/users/me)|^(/api/v1/system/config)|^(/api/v1/system/version)|^(/api/v1/tenants)|^(/api/v1/chats)|^(/api/v1/searches)|^(/api/v1/files)|^(/api/v1/agents$)|^(/api/v1/agents/[^/]+/versions$)|^(/api/v1/agents/[^/]+/upload$)': { target: 'http://127.0.0.1:9384/', changeOrigin: true,