From 9eb7cee473ca0405e1c99e1ab1726f4eee17d9bd Mon Sep 17 00:00:00 2001 From: Hz_ Date: Mon, 22 Jun 2026 18:17:37 +0800 Subject: [PATCH] feat(go-api): migrate searchbot share detail endpoint to go (#16124) ## Summary - add public Go route for `/api/v1/searchbots/detail` - implement beta-token auth flow for shared search access - add tenant-based access check for shared search apps - add joined search detail query for the share response - align Go response shape with the current Python runtime behavior - add DAO / service / handler tests for the new endpoint --- internal/dao/api_token.go | 10 + internal/dao/api_token_beta_test.go | 71 +++++ internal/dao/search.go | 44 +++ internal/dao/search_detail_test.go | 138 ++++++++ internal/handler/searchbot.go | 87 +++-- internal/handler/searchbot_detail_test.go | 315 +++++++++++++++++++ internal/router/router.go | 3 + internal/service/search.go | 38 +++ internal/service/search_share_detail_test.go | 154 +++++++++ internal/service/user.go | 42 +++ internal/service/user_beta_test.go | 138 ++++++++ 11 files changed, 1013 insertions(+), 27 deletions(-) create mode 100644 internal/dao/api_token_beta_test.go create mode 100644 internal/dao/search_detail_test.go create mode 100644 internal/handler/searchbot_detail_test.go create mode 100644 internal/service/search_share_detail_test.go create mode 100644 internal/service/user_beta_test.go diff --git a/internal/dao/api_token.go b/internal/dao/api_token.go index 8eff2aae2..a387b2f8e 100644 --- a/internal/dao/api_token.go +++ b/internal/dao/api_token.go @@ -58,6 +58,16 @@ func (dao *APITokenDAO) GetUserByAPIToken(token string) (*entity.APIToken, error return &apiToken, nil } +// GetByBeta gets API token by beta access key. +func (dao *APITokenDAO) GetByBeta(beta string) (*entity.APIToken, error) { + var apiToken entity.APIToken + err := DB.Where("beta = ?", beta).First(&apiToken).Error + if err != nil { + return nil, err + } + return &apiToken, nil +} + // DeleteByDialogIDs deletes API tokens by dialog IDs (hard delete) func (dao *APITokenDAO) DeleteByDialogIDs(dialogIDs []string) (int64, error) { if len(dialogIDs) == 0 { diff --git a/internal/dao/api_token_beta_test.go b/internal/dao/api_token_beta_test.go new file mode 100644 index 000000000..afb1b9a8d --- /dev/null +++ b/internal/dao/api_token_beta_test.go @@ -0,0 +1,71 @@ +// +// 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 dao + +import ( + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + "ragflow/internal/entity" +) + +func setupAPITokenBetaTestDB(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.APIToken{}); err != nil { + t.Fatalf("failed to migrate api_token: %v", err) + } + + return db +} + +func TestAPITokenDAOGetByBeta(t *testing.T) { + db := setupAPITokenBetaTestDB(t) + pushDB(t, db) + + beta := "beta-token" + if err := db.Create(&entity.APIToken{ + TenantID: "tenant-1", + Token: "token-1", + Beta: &beta, + }).Error; err != nil { + t.Fatalf("failed to create api token: %v", err) + } + + got, err := NewAPITokenDAO().GetByBeta(beta) + if err != nil { + t.Fatalf("GetByBeta failed: %v", err) + } + if got == nil { + t.Fatal("expected token, got nil") + } + if got.TenantID != "tenant-1" { + t.Fatalf("TenantID = %q, want tenant-1", got.TenantID) + } + if got.Beta == nil || *got.Beta != beta { + t.Fatalf("Beta = %v, want %q", got.Beta, beta) + } +} diff --git a/internal/dao/search.go b/internal/dao/search.go index 81ee5d52e..ca5b26013 100644 --- a/internal/dao/search.go +++ b/internal/dao/search.go @@ -29,6 +29,21 @@ func NewSearchDAO() *SearchDAO { return &SearchDAO{} } +// SearchDetailRow represents the joined detail payload used by the +// share-detail endpoint. +type SearchDetailRow struct { + ID string `gorm:"column:id"` + Avatar *string `gorm:"column:avatar"` + TenantID string `gorm:"column:tenant_id"` + Name string `gorm:"column:name"` + Description *string `gorm:"column:description"` + CreatedBy string `gorm:"column:created_by"` + SearchConfig entity.JSONMap `gorm:"column:search_config"` + UpdateTime *int64 `gorm:"column:update_time"` + Nickname *string `gorm:"column:nickname"` + TenantAvatar *string `gorm:"column:tenant_avatar"` +} + // ListByTenantIDs list searches by tenant IDs with pagination and filtering func (dao *SearchDAO) ListByTenantIDs(tenantIDs []string, userID string, page, pageSize int, orderby string, desc bool, keywords string) ([]*entity.Search, int64, error) { var searches []*entity.Search @@ -125,6 +140,35 @@ func (dao *SearchDAO) GetByID(id string) (*entity.Search, error) { return &search, nil } +// GetDetailByID retrieves the share-detail payload by joining the search app +// with its owner profile, matching Python SearchService.get_detail. +func (dao *SearchDAO) GetDetailByID(searchID string) (*SearchDetailRow, error) { + var detail SearchDetailRow + err := DB.Table("search"). + Select(` + search.id, + search.avatar, + search.tenant_id, + search.name, + search.description, + search.created_by, + search.search_config, + search.update_time, + user.nickname, + user.avatar AS tenant_avatar + `). + Joins("JOIN user ON user.id = search.tenant_id AND user.status = ?", "1"). + Where("search.id = ? AND search.status = ?", searchID, "1"). + Scan(&detail).Error + if err != nil { + return nil, err + } + if detail.ID == "" { + return nil, nil + } + return &detail, nil +} + // GetByNameAndTenant gets search by name and tenant ID func (dao *SearchDAO) GetByNameAndTenant(name string, tenantID string) ([]*entity.Search, error) { var searches []*entity.Search diff --git a/internal/dao/search_detail_test.go b/internal/dao/search_detail_test.go new file mode 100644 index 000000000..6691fe19e --- /dev/null +++ b/internal/dao/search_detail_test.go @@ -0,0 +1,138 @@ +// +// 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 dao + +import ( + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + "ragflow/internal/entity" +) + +func setupSearchDetailDAOTestDB(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.Search{}); err != nil { + t.Fatalf("failed to migrate: %v", err) + } + + return db +} + +func TestSearchDAOGetDetailByID(t *testing.T) { + db := setupSearchDetailDAOTestDB(t) + pushDB(t, db) + + userStatus := "1" + userAvatar := "tenant-avatar" + if err := db.Create(&entity.User{ + ID: "tenant-1", + Email: "tenant@example.com", + Nickname: "Tenant Nick", + Avatar: &userAvatar, + IsAuthenticated: "1", + IsActive: "1", + IsAnonymous: "0", + Status: &userStatus, + }).Error; err != nil { + t.Fatalf("failed to create user: %v", err) + } + + searchStatus := "1" + searchAvatar := "search-avatar" + if err := db.Create(&entity.Search{ + ID: "search-1", + Avatar: &searchAvatar, + TenantID: "tenant-1", + Name: "Search App", + CreatedBy: "tenant-1", + SearchConfig: entity.JSONMap{"summary": true}, + Status: &searchStatus, + }).Error; err != nil { + t.Fatalf("failed to create search: %v", err) + } + + detail, err := NewSearchDAO().GetDetailByID("search-1") + if err != nil { + t.Fatalf("GetDetailByID failed: %v", err) + } + if detail == nil { + t.Fatal("expected detail, got nil") + } + if detail.ID != "search-1" { + t.Fatalf("ID = %q, want search-1", detail.ID) + } + if detail.Avatar == nil || *detail.Avatar != searchAvatar { + t.Fatalf("Avatar = %v, want %q", detail.Avatar, searchAvatar) + } + if detail.Nickname == nil || *detail.Nickname != "Tenant Nick" { + t.Fatalf("Nickname = %v, want Tenant Nick", detail.Nickname) + } + if detail.TenantAvatar == nil || *detail.TenantAvatar != userAvatar { + t.Fatalf("TenantAvatar = %v, want %q", detail.TenantAvatar, userAvatar) + } + if got := detail.SearchConfig["summary"]; got != true { + t.Fatalf("search_config.summary = %v, want true", got) + } +} + +func TestSearchDAOGetDetailByIDReturnsNilWhenJoinedUserIsInactive(t *testing.T) { + db := setupSearchDetailDAOTestDB(t) + pushDB(t, db) + + userStatus := "0" + if err := db.Create(&entity.User{ + ID: "tenant-1", + Email: "tenant@example.com", + Nickname: "Tenant Nick", + IsAuthenticated: "1", + IsActive: "1", + IsAnonymous: "0", + Status: &userStatus, + }).Error; err != nil { + t.Fatalf("failed to create user: %v", err) + } + + searchStatus := "1" + if err := db.Create(&entity.Search{ + ID: "search-1", + TenantID: "tenant-1", + Name: "Search App", + CreatedBy: "tenant-1", + SearchConfig: entity.JSONMap{"summary": true}, + Status: &searchStatus, + }).Error; err != nil { + t.Fatalf("failed to create search: %v", err) + } + + detail, err := NewSearchDAO().GetDetailByID("search-1") + if err != nil { + t.Fatalf("GetDetailByID failed: %v", err) + } + if detail != nil { + t.Fatalf("expected nil detail when joined user is inactive, got %+v", detail) + } +} diff --git a/internal/handler/searchbot.go b/internal/handler/searchbot.go index 56ec1eb09..e549c8cf8 100644 --- a/internal/handler/searchbot.go +++ b/internal/handler/searchbot.go @@ -45,7 +45,6 @@ type ChunkRetriever interface { RetrievalTest(req *service.RetrievalTestRequest, userID string) (*service.RetrievalTestResponse, error) } - // streamingLLM abstracts streaming chat for the Ask endpoint. // The returned channel delivers raw text deltas from the LLM. // Implementations should respect ctx cancellation to prevent goroutine leaks. @@ -55,9 +54,9 @@ type streamingLLM interface { // SearchBotAskRequest is the request body for POST /api/v1/searchbots/ask. type SearchBotAskRequest struct { - Question string `json:"question" binding:"required"` - KbIDs common.StringSlice `json:"kb_ids" binding:"required"` - SearchID string `json:"search_id,omitempty"` + Question string `json:"question" binding:"required"` + KbIDs common.StringSlice `json:"kb_ids" binding:"required"` + SearchID string `json:"search_id,omitempty"` } // SearchBotRealLLM wraps ModelProviderService to implement searchbotLLM. @@ -111,21 +110,21 @@ func chatStreamWithContext(ctx context.Context, chatModel *modelModule.ChatModel // SearchBotRetrievalTestRequest is the request body for POST /api/v1/searchbots/retrieval_test. type SearchBotRetrievalTestRequest struct { - KbIDs common.StringSlice `json:"kb_ids" binding:"required"` - Question string `json:"question" binding:"required"` - Page *int `json:"page,omitempty"` - Size *int `json:"size,omitempty"` - DocIDs []string `json:"doc_ids,omitempty"` - UseKG *bool `json:"use_kg,omitempty"` - TopK *int `json:"top_k,omitempty"` - CrossLanguages []string `json:"cross_languages,omitempty"` - SearchID *string `json:"search_id,omitempty"` + KbIDs common.StringSlice `json:"kb_ids" binding:"required"` + Question string `json:"question" binding:"required"` + Page *int `json:"page,omitempty"` + Size *int `json:"size,omitempty"` + DocIDs []string `json:"doc_ids,omitempty"` + UseKG *bool `json:"use_kg,omitempty"` + TopK *int `json:"top_k,omitempty"` + CrossLanguages []string `json:"cross_languages,omitempty"` + SearchID *string `json:"search_id,omitempty"` MetaDataFilter map[string]interface{} `json:"meta_data_filter,omitempty"` - TenantRerankID *string `json:"tenant_rerank_id,omitempty"` - RerankID *string `json:"rerank_id,omitempty"` - Keyword *bool `json:"keyword,omitempty"` - SimilarityThreshold *float64 `json:"similarity_threshold,omitempty"` - VectorSimilarityWeight *float64 `json:"vector_similarity_weight,omitempty"` + TenantRerankID *string `json:"tenant_rerank_id,omitempty"` + RerankID *string `json:"rerank_id,omitempty"` + Keyword *bool `json:"keyword,omitempty"` + SimilarityThreshold *float64 `json:"similarity_threshold,omitempty"` + VectorSimilarityWeight *float64 `json:"vector_similarity_weight,omitempty"` // TODO: wire highlight to nlp Retrieval when engine supports highlightFields // Python: bot_api.py → retrieval(highlight=req.get("highlight")) // → search.py highlightFields → ES get_highlight() @@ -140,9 +139,10 @@ type SearchBotRequest struct { } // SearchBotHandler handles searchbot endpoints: -// POST /api/v1/searchbots/related_questions -// POST /api/v1/searchbots/retrieval_test -// POST /api/v1/searchbots/ask +// +// POST /api/v1/searchbots/related_questions +// POST /api/v1/searchbots/retrieval_test +// POST /api/v1/searchbots/ask type SearchBotHandler struct { searchSvc *service.SearchService tenantSvc *service.TenantService @@ -404,6 +404,39 @@ func (h *SearchBotHandler) Ask(c *gin.Context) { } +// SearchbotDetail returns the public share-page bootstrap payload for a +// search app. The route is mounted under apiNoAuth but still requires a beta +// token, matching Python's AUTH_BETA flow. +func (h *SearchBotHandler) SearchbotDetail(c *gin.Context) { + searchID := strings.TrimSpace(c.Query("search_id")) + if searchID == "" { + jsonError(c, common.CodeArgumentError, "search_id is required") + return + } + + userSvc := service.NewUserService() + user, code, err := userSvc.GetUserByBetaAPIToken(c.GetHeader("Authorization")) + if err != nil { + jsonError(c, code, "Authentication error: API key is invalid!") + return + } + + detail, err := h.searchSvc.GetSearchShareDetail(user.ID, searchID) + if err != nil { + switch err.Error() { + case "has no permission for this operation": + jsonError(c, common.CodeOperatingError, "Has no permission for this operation.") + case "can't find this Search App!": + jsonError(c, common.CodeDataError, "Can't find this Search App!") + default: + jsonInternalError(c, err) + } + return + } + + jsonResponse(c, common.CodeSuccess, detail, "success") +} + // ---- SSE helpers ---- type ssePayload struct { @@ -416,11 +449,11 @@ type ssePayload struct { // The Reference field is always present (non-nil) so the frontend can safely // access .chunks or .reduce without a null guard. type askSSEData struct { - Answer string `json:"answer"` - Reference interface{} `json:"reference"` - Final bool `json:"final"` - StartToThink bool `json:"start_to_think,omitempty"` - EndToThink bool `json:"end_to_think,omitempty"` + Answer string `json:"answer"` + Reference interface{} `json:"reference"` + Final bool `json:"final"` + StartToThink bool `json:"start_to_think,omitempty"` + EndToThink bool `json:"end_to_think,omitempty"` } func sseAnswer(answer string, refs interface{}, final bool) string { @@ -515,7 +548,7 @@ func toRetrievalServiceRequest(h *SearchBotRetrievalTestRequest) *service.Retrie // ptrFloat64 returns a pointer to a float64 value. func ptrFloat64(v float64) *float64 { return &v } -func intPtr(v int) *int { return &v } +func intPtr(v int) *int { return &v } func floatPtr(v float64) *float64 { return &v } // applyRetrievalDefaults fills in default values for optional fields, diff --git a/internal/handler/searchbot_detail_test.go b/internal/handler/searchbot_detail_test.go new file mode 100644 index 000000000..479db34c1 --- /dev/null +++ b/internal/handler/searchbot_detail_test.go @@ -0,0 +1,315 @@ +// +// 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" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "ragflow/internal/common" + "ragflow/internal/dao" + "ragflow/internal/entity" + "ragflow/internal/service" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func setupSearchbotDetailHandlerDB(t *testing.T) { + 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.UserTenant{}, &entity.Search{}, &entity.APIToken{}); err != nil { + t.Fatalf("failed to migrate: %v", err) + } + + orig := dao.DB + dao.DB = db + t.Cleanup(func() { + dao.DB = orig + }) +} + +func newSearchbotDetailRouter() *gin.Engine { + gin.SetMode(gin.TestMode) + h := NewSearchBotHandler(service.NewSearchService(), nil, nil, nil) + r := gin.New() + r.GET("/api/v1/searchbots/detail", h.SearchbotDetail) + return r +} + +func TestSearchbotDetailSuccess(t *testing.T) { + setupSearchbotDetailHandlerDB(t) + + status := "1" + accessToken := "access-token" + tenantAvatar := "tenant-avatar" + beta := "beta-token" + searchAvatar := "search-avatar" + + if err := dao.DB.Create(&entity.User{ + ID: "tenant-1", + Email: "tenant@example.com", + Nickname: "Tenant", + Avatar: &tenantAvatar, + AccessToken: &accessToken, + IsAuthenticated: "1", + IsActive: "1", + IsAnonymous: "0", + Status: &status, + }).Error; err != nil { + t.Fatalf("failed to create user: %v", err) + } + + if err := dao.DB.Create(&entity.UserTenant{ + ID: "ut-1", + UserID: "tenant-1", + TenantID: "tenant-1", + Role: "owner", + InvitedBy: "tenant-1", + Status: &status, + }).Error; err != nil { + t.Fatalf("failed to create user_tenant: %v", err) + } + + if err := dao.DB.Create(&entity.APIToken{ + TenantID: "tenant-1", + Token: "token-1", + Beta: &beta, + }).Error; err != nil { + t.Fatalf("failed to create api token: %v", err) + } + + if err := dao.DB.Create(&entity.Search{ + ID: "search-1", + Avatar: &searchAvatar, + TenantID: "tenant-1", + Name: "Search App", + CreatedBy: "tenant-1", + SearchConfig: entity.JSONMap{"summary": true}, + Status: &status, + }).Error; err != nil { + t.Fatalf("failed to create search: %v", err) + } + + r := newSearchbotDetailRouter() + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/searchbots/detail?search_id=search-1", nil) + req.Header.Set("Authorization", "Bearer "+beta) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if resp["code"] != float64(common.CodeSuccess) { + t.Fatalf("code = %v, want %v", resp["code"], common.CodeSuccess) + } + data := resp["data"].(map[string]interface{}) + if data["id"] != "search-1" { + t.Fatalf("id = %v, want search-1", data["id"]) + } + if _, ok := data["avatar"]; !ok { + t.Fatal("expected avatar key in response") + } + if data["avatar"] != searchAvatar { + t.Fatalf("avatar = %v, want %q", data["avatar"], searchAvatar) + } + if _, ok := data["nickname"]; ok { + t.Fatalf("did not expect nickname in response, got %v", data["nickname"]) + } + if _, ok := data["tenant_avatar"]; ok { + t.Fatalf("did not expect tenant_avatar in response, got %v", data["tenant_avatar"]) + } +} + +func TestSearchbotDetailRejectsMissingSearchID(t *testing.T) { + setupSearchbotDetailHandlerDB(t) + + r := newSearchbotDetailRouter() + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/searchbots/detail", nil) + r.ServeHTTP(w, req) + + var resp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if resp["code"] != float64(common.CodeArgumentError) { + t.Fatalf("code = %v, want %v", resp["code"], common.CodeArgumentError) + } +} + +func TestSearchbotDetailRejectsInvalidBetaToken(t *testing.T) { + setupSearchbotDetailHandlerDB(t) + + r := newSearchbotDetailRouter() + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/searchbots/detail?search_id=search-1", nil) + req.Header.Set("Authorization", "Bearer missing") + r.ServeHTTP(w, req) + + var resp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if resp["code"] != float64(common.CodeUnauthorized) { + t.Fatalf("code = %v, want %v", resp["code"], common.CodeUnauthorized) + } +} + +func TestSearchbotDetailRejectsUnauthorizedSearchAccess(t *testing.T) { + setupSearchbotDetailHandlerDB(t) + + status := "1" + beta := "beta-token" + accessToken := "access-token" + + for _, u := range []entity.User{ + { + ID: "tenant-1", + Email: "tenant1@example.com", + Nickname: "Tenant1", + IsAuthenticated: "1", + IsActive: "1", + IsAnonymous: "0", + Status: &status, + }, + { + ID: "user-2", + Email: "user2@example.com", + Nickname: "User2", + AccessToken: &accessToken, + IsAuthenticated: "1", + IsActive: "1", + IsAnonymous: "0", + Status: &status, + }, + } { + user := u + if err := dao.DB.Create(&user).Error; err != nil { + t.Fatalf("failed to create user %s: %v", user.ID, err) + } + } + + if err := dao.DB.Create(&entity.UserTenant{ + ID: "ut-1", + UserID: "user-2", + TenantID: "user-2", + Role: "owner", + InvitedBy: "user-2", + Status: &status, + }).Error; err != nil { + t.Fatalf("failed to create user_tenant: %v", err) + } + + if err := dao.DB.Create(&entity.APIToken{ + TenantID: "user-2", + Token: "token-1", + Beta: &beta, + }).Error; err != nil { + t.Fatalf("failed to create api token: %v", err) + } + + if err := dao.DB.Create(&entity.Search{ + ID: "search-1", + TenantID: "tenant-1", + Name: "Search App", + CreatedBy: "tenant-1", + SearchConfig: entity.JSONMap{"summary": true}, + Status: &status, + }).Error; err != nil { + t.Fatalf("failed to create search: %v", err) + } + + r := newSearchbotDetailRouter() + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/searchbots/detail?search_id=search-1", nil) + req.Header.Set("Authorization", "Bearer "+beta) + r.ServeHTTP(w, req) + + var resp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if resp["code"] != float64(common.CodeOperatingError) { + t.Fatalf("code = %v, want %v", resp["code"], common.CodeOperatingError) + } +} + +func TestSearchbotDetailDoesNotExposeInternalErrorText(t *testing.T) { + setupSearchbotDetailHandlerDB(t) + + status := "1" + accessToken := "access-token" + beta := "beta-token" + if err := dao.DB.Create(&entity.User{ + ID: "tenant-1", + Email: "tenant@example.com", + Nickname: "Tenant", + AccessToken: &accessToken, + IsAuthenticated: "1", + IsActive: "1", + IsAnonymous: "0", + Status: &status, + }).Error; err != nil { + t.Fatalf("failed to create user: %v", err) + } + if err := dao.DB.Create(&entity.APIToken{ + TenantID: "tenant-1", + Token: "token-1", + Beta: &beta, + }).Error; err != nil { + t.Fatalf("failed to create api token: %v", err) + } + if err := dao.DB.Exec("DROP TABLE user_tenant").Error; err != nil { + t.Fatalf("failed to drop user_tenant table: %v", err) + } + + r := newSearchbotDetailRouter() + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/searchbots/detail?search_id=search-1", nil) + req.Header.Set("Authorization", "Bearer "+beta) + r.ServeHTTP(w, req) + + body := w.Body.String() + if strings.Contains(body, "no such table") { + t.Fatalf("response leaked internal error text: %s", body) + } + + var resp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if resp["code"] != float64(common.CodeServerError) { + t.Fatalf("code = %v, want %v", resp["code"], common.CodeServerError) + } +} diff --git a/internal/router/router.go b/internal/router/router.go index a9f73457a..2978c8b9d 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -146,6 +146,9 @@ func (r *Router) Setup(engine *gin.Engine) { apiNoAuth.GET("/system/version", r.systemHandler.GetVersion) apiNoAuth.GET("/system/healthz", r.systemHandler.Healthz) + // searchbots + apiNoAuth.GET("/searchbots/detail", r.searchBotHandler.SearchbotDetail) + // User login channels endpoint apiNoAuth.GET("/auth/login/channels", r.userHandler.GetLoginChannels) diff --git a/internal/service/search.go b/internal/service/search.go index dcc8f7f12..c78d79289 100644 --- a/internal/service/search.go +++ b/internal/service/search.go @@ -55,6 +55,17 @@ type ListSearchAppsResponse struct { Total int64 `json:"total"` } +type SearchShareDetail struct { + ID string `json:"id"` + Avatar *string `json:"avatar"` + TenantID string `json:"tenant_id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + CreatedBy string `json:"created_by"` + SearchConfig map[string]interface{} `json:"search_config"` + UpdateTime *int64 `json:"update_time,omitempty"` +} + // ListSearches list search apps with advanced filtering (equivalent to list_search_app) func (s *SearchService) ListSearches(userID string, keywords string, page, pageSize int, orderby string, desc bool, ownerIDs []string) (*ListSearchAppsResponse, error) { var searches []*entity.Search @@ -224,6 +235,33 @@ func (s *SearchService) GetSearchDetail(userID string, searchID string) (*entity return search, nil } +// GetSearchShareDetail returns the joined share-detail payload for public +// searchbot pages after verifying the caller can access the search app. +func (s *SearchService) GetSearchShareDetail(userID, searchID string) (*SearchShareDetail, error) { + if _, err := s.GetSearchDetail(userID, searchID); err != nil { + return nil, err + } + + detail, err := s.searchDAO.GetDetailByID(searchID) + if err != nil { + return nil, err + } + if detail == nil { + return nil, fmt.Errorf("can't find this Search App!") + } + + return &SearchShareDetail{ + ID: detail.ID, + Avatar: detail.Avatar, + TenantID: detail.TenantID, + Name: detail.Name, + Description: detail.Description, + CreatedBy: detail.CreatedBy, + SearchConfig: map[string]interface{}(detail.SearchConfig), + UpdateTime: detail.UpdateTime, + }, nil +} + // DeleteSearch deletes a search app by ID func (s *SearchService) DeleteSearch(userID string, searchID string) error { // Step 1: Check deletion permission (same as Python SearchService.accessible4deletion) diff --git a/internal/service/search_share_detail_test.go b/internal/service/search_share_detail_test.go new file mode 100644 index 000000000..87ec220ab --- /dev/null +++ b/internal/service/search_share_detail_test.go @@ -0,0 +1,154 @@ +// +// 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 service + +import ( + "strings" + "testing" + + "ragflow/internal/dao" + "ragflow/internal/entity" +) + +func setupSearchShareServiceDB(t *testing.T) { + t.Helper() + db := setupServiceTestDB(t) + if err := db.AutoMigrate(&entity.Search{}); err != nil { + t.Fatalf("failed to migrate search: %v", err) + } + pushServiceDB(t, db) +} + +func TestSearchServiceGetSearchShareDetail(t *testing.T) { + setupSearchShareServiceDB(t) + + status := "1" + avatar := "tenant-avatar" + accessToken := "access-token" + if err := dao.DB.Create(&entity.User{ + ID: "tenant-1", + Email: "tenant@example.com", + Nickname: "Tenant", + Avatar: &avatar, + AccessToken: &accessToken, + IsAuthenticated: "1", + IsActive: "1", + IsAnonymous: "0", + Status: &status, + }).Error; err != nil { + t.Fatalf("failed to create user: %v", err) + } + + if err := dao.DB.Create(&entity.UserTenant{ + ID: "ut-1", + UserID: "tenant-1", + TenantID: "tenant-1", + Role: "owner", + InvitedBy: "tenant-1", + Status: &status, + }).Error; err != nil { + t.Fatalf("failed to create user_tenant: %v", err) + } + + searchAvatar := "search-avatar" + if err := dao.DB.Create(&entity.Search{ + ID: "search-1", + Avatar: &searchAvatar, + TenantID: "tenant-1", + Name: "Search App", + CreatedBy: "tenant-1", + SearchConfig: entity.JSONMap{"summary": true}, + Status: &status, + }).Error; err != nil { + t.Fatalf("failed to create search: %v", err) + } + + detail, err := NewSearchService().GetSearchShareDetail("tenant-1", "search-1") + if err != nil { + t.Fatalf("GetSearchShareDetail failed: %v", err) + } + if detail == nil { + t.Fatal("expected detail, got nil") + } + if detail.Avatar == nil || *detail.Avatar != searchAvatar { + t.Fatalf("Avatar = %v, want %q", detail.Avatar, searchAvatar) + } + if got := detail.SearchConfig["summary"]; got != true { + t.Fatalf("search_config.summary = %v, want true", got) + } +} + +func TestSearchServiceGetSearchShareDetailRejectsUnauthorizedUser(t *testing.T) { + setupSearchShareServiceDB(t) + + status := "1" + for _, u := range []entity.User{ + { + ID: "tenant-1", + Email: "tenant1@example.com", + Nickname: "Tenant1", + IsAuthenticated: "1", + IsActive: "1", + IsAnonymous: "0", + Status: &status, + }, + { + ID: "user-2", + Email: "user2@example.com", + Nickname: "User2", + IsAuthenticated: "1", + IsActive: "1", + IsAnonymous: "0", + Status: &status, + }, + } { + user := u + if err := dao.DB.Create(&user).Error; err != nil { + t.Fatalf("failed to create user %s: %v", user.ID, err) + } + } + + if err := dao.DB.Create(&entity.UserTenant{ + ID: "ut-1", + UserID: "user-2", + TenantID: "user-2", + Role: "owner", + InvitedBy: "user-2", + Status: &status, + }).Error; err != nil { + t.Fatalf("failed to create user_tenant: %v", err) + } + + if err := dao.DB.Create(&entity.Search{ + ID: "search-1", + TenantID: "tenant-1", + Name: "Search App", + CreatedBy: "tenant-1", + SearchConfig: entity.JSONMap{"summary": true}, + Status: &status, + }).Error; err != nil { + t.Fatalf("failed to create search: %v", err) + } + + _, err := NewSearchService().GetSearchShareDetail("user-2", "search-1") + if err == nil { + t.Fatal("expected permission error") + } + if !strings.Contains(err.Error(), "has no permission") { + t.Fatalf("err = %v, want permission error", err) + } +} diff --git a/internal/service/user.go b/internal/service/user.go index 03615e3f5..22a43b1d1 100644 --- a/internal/service/user.go +++ b/internal/service/user.go @@ -1066,6 +1066,48 @@ func (s *UserService) GetUserByAPIToken(authorization string) (*entity.User, com } +// GetUserByBetaAPIToken gets user by beta access key from Authorization +// header. This mirrors Python's AUTH_BETA flow used by public bot endpoints. +func (s *UserService) GetUserByBetaAPIToken(authorization string) (*entity.User, common.ErrorCode, error) { + authorization = strings.TrimSpace(authorization) + if authorization == "" { + return nil, common.CodeUnauthorized, fmt.Errorf("authorization header is empty") + } + + parts := strings.Fields(authorization) + var token string + if len(parts) == 2 { + token = parts[1] + } else if len(parts) == 1 { + if strings.EqualFold(parts[0], "Bearer") { + return nil, common.CodeUnauthorized, fmt.Errorf("invalid authorization format") + } + token = parts[0] + } else { + return nil, common.CodeUnauthorized, fmt.Errorf("invalid authorization format") + } + if token == "" { + return nil, common.CodeUnauthorized, fmt.Errorf("invalid authorization format") + } + + apiTokenDAO := dao.NewAPITokenDAO() + userToken, err := apiTokenDAO.GetByBeta(token) + if err != nil { + return nil, common.CodeUnauthorized, fmt.Errorf("invalid beta access token") + } + + user, err := s.userDAO.GetByTenantID(userToken.TenantID) + if err != nil { + return nil, common.CodeUnauthorized, fmt.Errorf("user not found for this beta access token") + } + + if user.AccessToken == nil || *user.AccessToken == "" { + return nil, common.CodeUnauthorized, fmt.Errorf("user has empty access_token in database") + } + + return user, common.CodeSuccess, nil +} + // ---- Forgot-password flow (mirrors api/apps/restful_apis/user_api.py // `/auth/password/...` endpoints, fixes #15282) ------------------------- diff --git a/internal/service/user_beta_test.go b/internal/service/user_beta_test.go new file mode 100644 index 000000000..470d2c783 --- /dev/null +++ b/internal/service/user_beta_test.go @@ -0,0 +1,138 @@ +// +// 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 service + +import ( + "testing" + + "ragflow/internal/common" + "ragflow/internal/dao" + "ragflow/internal/entity" +) + +func setupUserBetaServiceDB(t *testing.T) { + t.Helper() + db := setupServiceTestDB(t) + if err := db.AutoMigrate(&entity.APIToken{}); err != nil { + t.Fatalf("failed to migrate api token: %v", err) + } + pushServiceDB(t, db) +} + +func TestUserServiceGetUserByBetaAPIToken(t *testing.T) { + setupUserBetaServiceDB(t) + + accessToken := "access-token" + status := "1" + if err := dao.DB.Create(&entity.User{ + ID: "tenant-1", + Email: "tenant@example.com", + Nickname: "Tenant", + AccessToken: &accessToken, + IsAuthenticated: "1", + IsActive: "1", + IsAnonymous: "0", + Status: &status, + }).Error; err != nil { + t.Fatalf("failed to create user: %v", err) + } + + beta := "beta-token" + if err := dao.DB.Create(&entity.APIToken{ + TenantID: "tenant-1", + Token: "token-1", + Beta: &beta, + }).Error; err != nil { + t.Fatalf("failed to create api token: %v", err) + } + + svc := NewUserService() + for _, auth := range []string{"Bearer " + beta, beta, "Bearer " + beta} { + user, code, err := svc.GetUserByBetaAPIToken(auth) + if err != nil { + t.Fatalf("GetUserByBetaAPIToken(%q) failed: %v", auth, err) + } + if code != common.CodeSuccess { + t.Fatalf("code = %v, want %v", code, common.CodeSuccess) + } + if user == nil || user.ID != "tenant-1" { + t.Fatalf("user = %+v, want tenant-1", user) + } + } +} + +func TestUserServiceGetUserByBetaAPITokenRejectsInvalidToken(t *testing.T) { + setupUserBetaServiceDB(t) + + _, code, err := NewUserService().GetUserByBetaAPIToken("Bearer missing") + if err == nil { + t.Fatal("expected error for invalid beta token") + } + if code != common.CodeUnauthorized { + t.Fatalf("code = %v, want %v", code, common.CodeUnauthorized) + } +} + +func TestUserServiceGetUserByBetaAPITokenRejectsEmptyOrWhitespaceToken(t *testing.T) { + setupUserBetaServiceDB(t) + + for _, auth := range []string{"Bearer ", " ", "\t"} { + _, code, err := NewUserService().GetUserByBetaAPIToken(auth) + if err == nil { + t.Fatalf("expected error for auth %q", auth) + } + if code != common.CodeUnauthorized { + t.Fatalf("code = %v, want %v for auth %q", code, common.CodeUnauthorized, auth) + } + } +} + +func TestUserServiceGetUserByBetaAPITokenRejectsUserWithEmptyAccessToken(t *testing.T) { + setupUserBetaServiceDB(t) + + status := "1" + emptyToken := "" + if err := dao.DB.Create(&entity.User{ + ID: "tenant-1", + Email: "tenant@example.com", + Nickname: "Tenant", + AccessToken: &emptyToken, + IsAuthenticated: "1", + IsActive: "1", + IsAnonymous: "0", + Status: &status, + }).Error; err != nil { + t.Fatalf("failed to create user: %v", err) + } + + beta := "beta-token" + if err := dao.DB.Create(&entity.APIToken{ + TenantID: "tenant-1", + Token: "token-1", + Beta: &beta, + }).Error; err != nil { + t.Fatalf("failed to create api token: %v", err) + } + + _, code, err := NewUserService().GetUserByBetaAPIToken("Bearer " + beta) + if err == nil { + t.Fatal("expected error for empty access token") + } + if code != common.CodeUnauthorized { + t.Fatalf("code = %v, want %v", code, common.CodeUnauthorized) + } +}