fix: 帮助中心设置页报 Feature 'knowledge_base' is not available
三个问题:
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(自托管放行)。
This commit is contained in:
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user