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
This commit is contained in:
Hz_
2026-06-22 18:17:37 +08:00
committed by GitHub
parent 2856cde2d1
commit 9eb7cee473
11 changed files with 1013 additions and 27 deletions

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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,

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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) -------------------------

View File

@@ -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)
}
}