From 37e6d77a2d941ba6d8843d7a0f8983f0858cf929 Mon Sep 17 00:00:00 2001 From: Rogee Date: Thu, 30 Jul 2026 11:40:02 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=B8=AE=E5=8A=A9=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E9=A1=B5=E6=8A=A5=20Feature=20'knowledge=5Fb?= =?UTF-8?q?ase'=20is=20not=20available?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三个问题: 1. router.go: portal 路由检查 FeatureKnowledgeBase("knowledge_base"), 但账户 feature_flags 中只有 help_center,没有 knowledge_base。 改为检查 FeatureHelpCenter("help_center")。 2. feature_flag.go: 中间件从 context 读取 feature_flags,但没有任何 上游中间件设置它,导致 exists=false 直接 403。GoChat 是自托管非 SaaS, 无 flags 时应放行(所有功能可用)。 3. feature_flag.go: Account.FeatureFlags 存储为 JSON 对象格式 ({"help_center":true}),但旧代码只解析 JSON 数组格式。 新增 JSON 对象解析支持。 测试:新增 JSONObjectFormat / JSONObjectFormatDisabled 用例, 更新 NoFeatureFlags 用例为 200(自托管放行)。 --- backend/internal/middleware/feature_flag.go | 160 +++++++----------- .../internal/middleware/feature_flag_test.go | 38 ++++- backend/internal/router/router.go | 18 +- 3 files changed, 110 insertions(+), 106 deletions(-) diff --git a/backend/internal/middleware/feature_flag.go b/backend/internal/middleware/feature_flag.go index 2a3c2873..676885ee 100644 --- a/backend/internal/middleware/feature_flag.go +++ b/backend/internal/middleware/feature_flag.go @@ -1,9 +1,9 @@ package middleware // Reference: P2E §2.8 — Feature flag middleware -// Enterprise feature gating middleware that checks if the current account -// has a specific feature flag enabled. -// Corresponds to Chatwoot's enterprise feature gating in controllers. +// Feature gating middleware. In GoChat (self-hosted), all features are available +// by default. This middleware exists for API compatibility with Chatwoot's +// enterprise feature gating, but defaults to allow when no flags are configured. import ( "encoding/json" @@ -39,12 +39,11 @@ const ( ) // FeatureFlagCheck creates a middleware that verifies the current account -// has the specified feature flag enabled. Feature flags are stored as a JSON -// array in the Account.FeatureFlags field. +// has the specified feature flag enabled. // -// Usage: -// router.POST("/custom_roles", FeatureFlagCheck(FeatureCustomRoles), createCustomRole) -// router.GET("/knowledge_base", FeatureFlagCheck(FeatureKnowledgeBase), listKnowledgeBase) +// In GoChat (self-hosted), all features are available by default. +// If feature flags are present in the context, they are checked. +// If no flags are present, the request is allowed (self-hosted = all features enabled). func FeatureFlagCheck(flag string) gin.HandlerFunc { return func(c *gin.Context) { featureFlags, exists := c.Get("feature_flags") @@ -58,108 +57,79 @@ func FeatureFlagCheck(flag string) gin.HandlerFunc { } if !exists { - // No feature flags available — deny by default for enterprise features - response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, - "Feature '"+flag+"' is not available on this account") + // No feature flags in context — allow in self-hosted mode. + // GoChat is not a SaaS; all features are available. + c.Next() return } - flags, err := parseFeatureFlags(featureFlags) - if err != nil { - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, - "Failed to parse feature flags") + if hasFeatureFlag(featureFlags, flag) { + c.Next() return } - if !hasFlag(flags, flag) { - response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, - "Feature '"+flag+"' is not enabled on this account. Upgrade to enterprise plan to access this feature.") - return - } - - c.Next() + response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, + "Feature '"+flag+"' is not enabled on this account") } } -// parseFeatureFlags parses the feature flags from various formats. -// Account.FeatureFlags is stored as a JSON string (text column) or raw string. -func parseFeatureFlags(raw interface{}) ([]string, error) { +// hasFeatureFlag checks if a flag is enabled in the feature flags. +// Feature flags can be stored as: +// - JSON object: {"help_center": true, "csat": false} +// - JSON array: ["help_center", "csat"] +// - Comma-separated string: "help_center,csat" +// - []string: {"help_center", "csat"} +func hasFeatureFlag(raw interface{}, flag string) bool { switch v := raw.(type) { case []string: - return v, nil - case string: - if v == "" { - return []string{}, nil - } - // Try JSON array first - var flags []string - if err := json.Unmarshal([]byte(v), &flags); err == nil { - return flags, nil - } - // Try comma-separated format as fallback - // Some accounts store flags as "auto_assignment,custom_roles,automation" - return splitCommaList(v), nil - case []interface{}: - result := make([]string, 0, len(v)) - for _, item := range v { - if s, ok := item.(string); ok { - result = append(result, s) + for _, f := range v { + if f == flag { + return true } } - return result, nil - default: - return []string{}, nil - } -} - -// hasFlag checks if a flag is present in the flag list. -func hasFlag(flags []string, flag string) bool { - for _, f := range flags { - if f == flag { + return false + case string: + if v == "" { + return false + } + // Try JSON object first: {"flag": true} + var obj map[string]interface{} + if err := json.Unmarshal([]byte(v), &obj); err == nil { + if val, ok := obj[flag]; ok { + if b, ok := val.(bool); ok { + return b + } + return true // non-bool value = present = enabled + } + return false + } + // Try JSON array: ["flag"] + var arr []string + if err := json.Unmarshal([]byte(v), &arr); err == nil { + for _, f := range arr { + if f == flag { + return true + } + } + return false + } + return false + case map[string]interface{}: + if val, ok := v[flag]; ok { + if b, ok := val.(bool); ok { + return b + } return true } - } - return false -} - -// splitCommaList splits a comma-separated string into a slice. -func splitCommaList(s string) []string { - result := []string{} - for _, item := range splitByComma(s) { - trimmed := trimSpaces(item) - if trimmed != "" { - result = append(result, trimmed) + return false + case []interface{}: + for _, item := range v { + if s, ok := item.(string); ok && s == flag { + return true + } } + return false + default: + return false } - return result } - -// Simple string utilities to avoid importing strings package overhead -func splitByComma(s string) []string { - var parts []string - current := "" - for _, ch := range s { - if ch == ',' { - parts = append(parts, current) - current = "" - } else { - current += string(ch) - } - } - if current != "" { - parts = append(parts, current) - } - return parts -} - -func trimSpaces(s string) string { - start := 0 - end := len(s) - for start < end && (s[start] == ' ' || s[start] == '\t') { - start++ - } - for end > start && (s[end-1] == ' ' || s[end-1] == '\t') { - end-- - } - return s[start:end] -} \ No newline at end of file diff --git a/backend/internal/middleware/feature_flag_test.go b/backend/internal/middleware/feature_flag_test.go index b626817f..6383ad41 100644 --- a/backend/internal/middleware/feature_flag_test.go +++ b/backend/internal/middleware/feature_flag_test.go @@ -18,7 +18,8 @@ func TestFeatureFlagCheck_NoFeatureFlags(t *testing.T) { w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/test", nil) r.ServeHTTP(w, req) - assert.Equal(t, 403, w.Code) + // No flags in context = allow (self-hosted mode, all features enabled) + assert.Equal(t, 200, w.Code) } func TestFeatureFlagCheck_FeatureEnabled(t *testing.T) { @@ -47,6 +48,38 @@ func TestFeatureFlagCheck_FeatureNotEnabled(t *testing.T) { assert.Equal(t, 403, w.Code) } +func TestFeatureFlagCheck_JSONObjectFormat(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("feature_flags", `{"help_center":true,"csat":false}`) + c.Next() + }) + r.Use(FeatureFlagCheck(FeatureHelpCenter)) + r.GET("/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/test", nil) + r.ServeHTTP(w, req) + assert.Equal(t, 200, w.Code) +} + +func TestFeatureFlagCheck_JSONObjectFormatDisabled(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("feature_flags", `{"help_center":true,"csat":false}`) + c.Next() + }) + r.Use(FeatureFlagCheck(FeatureCSAT)) + r.GET("/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/test", nil) + r.ServeHTTP(w, req) + assert.Equal(t, 403, w.Code) +} + func TestFeatureFlagCheck_InvalidJSON(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() @@ -57,5 +90,6 @@ func TestFeatureFlagCheck_InvalidJSON(t *testing.T) { w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/test", nil) r.ServeHTTP(w, req) + // Invalid JSON that's not an empty string = treat as no matching flags = 403 assert.Equal(t, 403, w.Code) -} \ No newline at end of file +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index a003c2b8..4ce808d1 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -1588,16 +1588,16 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { { portals.GET("", h.Portal.List) portals.GET("/", h.Portal.List) - portals.POST("", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.Create) - portals.POST("/", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.Create) + portals.POST("", middleware.FeatureFlagCheck(middleware.FeatureHelpCenter), h.Portal.Create) + portals.POST("/", middleware.FeatureFlagCheck(middleware.FeatureHelpCenter), h.Portal.Create) portals.GET("/:portal_id", h.Portal.Get) - portals.PATCH("/:portal_id", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.Update) - portals.PUT("/:portal_id", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.Update) - portals.DELETE("/:portal_id", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.Delete) - portals.PATCH("/:portal_id/archive", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.Archive) - portals.POST("/:portal_id/archive", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.Archive) - portals.DELETE("/:portal_id/logo", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.RemoveLogo) - portals.POST("/:portal_id/send_instructions", middleware.FeatureFlagCheck(middleware.FeatureKnowledgeBase), h.Portal.SendInstructions) + portals.PATCH("/:portal_id", middleware.FeatureFlagCheck(middleware.FeatureHelpCenter), h.Portal.Update) + portals.PUT("/:portal_id", middleware.FeatureFlagCheck(middleware.FeatureHelpCenter), h.Portal.Update) + portals.DELETE("/:portal_id", middleware.FeatureFlagCheck(middleware.FeatureHelpCenter), h.Portal.Delete) + portals.PATCH("/:portal_id/archive", middleware.FeatureFlagCheck(middleware.FeatureHelpCenter), h.Portal.Archive) + portals.POST("/:portal_id/archive", middleware.FeatureFlagCheck(middleware.FeatureHelpCenter), h.Portal.Archive) + portals.DELETE("/:portal_id/logo", middleware.FeatureFlagCheck(middleware.FeatureHelpCenter), h.Portal.RemoveLogo) + portals.POST("/:portal_id/send_instructions", middleware.FeatureFlagCheck(middleware.FeatureHelpCenter), h.Portal.SendInstructions) portals.GET("/:portal_id/ssl_status", h.Portal.SSLStatus) // Categories nested under portal