chore: git cache cleanup
This commit is contained in:
223
backend/internal/api/handlers/feature_flags_handler.go
Normal file
223
backend/internal/api/handlers/feature_flags_handler.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
)
|
||||
|
||||
// FeatureFlagsHandler exposes simple DB-backed feature flags with env fallback.
|
||||
type FeatureFlagsHandler struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
func NewFeatureFlagsHandler(db *gorm.DB) *FeatureFlagsHandler {
|
||||
return &FeatureFlagsHandler{DB: db}
|
||||
}
|
||||
|
||||
// defaultFlags lists the canonical feature flags we expose.
|
||||
var defaultFlags = []string{
|
||||
"feature.cerberus.enabled",
|
||||
"feature.uptime.enabled",
|
||||
"feature.crowdsec.console_enrollment",
|
||||
"feature.notifications.engine.notify_v1.enabled",
|
||||
"feature.notifications.service.discord.enabled",
|
||||
"feature.notifications.service.gotify.enabled",
|
||||
"feature.notifications.service.webhook.enabled",
|
||||
"feature.notifications.legacy.fallback_enabled",
|
||||
"feature.notifications.security_provider_events.enabled", // Blocker 3: Add security_provider_events gate
|
||||
}
|
||||
|
||||
var defaultFlagValues = map[string]bool{
|
||||
"feature.cerberus.enabled": false, // Cerberus OFF by default (per diagnostic fix)
|
||||
"feature.uptime.enabled": true, // Uptime enabled by default
|
||||
"feature.crowdsec.console_enrollment": false,
|
||||
"feature.notifications.engine.notify_v1.enabled": false,
|
||||
"feature.notifications.service.discord.enabled": false,
|
||||
"feature.notifications.service.gotify.enabled": false,
|
||||
"feature.notifications.service.webhook.enabled": false,
|
||||
"feature.notifications.legacy.fallback_enabled": false,
|
||||
"feature.notifications.security_provider_events.enabled": false, // Blocker 3: Default disabled for this stage
|
||||
}
|
||||
|
||||
var retiredLegacyFallbackEnvAliases = []string{
|
||||
"FEATURE_NOTIFICATIONS_LEGACY_FALLBACK_ENABLED",
|
||||
"NOTIFICATIONS_LEGACY_FALLBACK_ENABLED",
|
||||
}
|
||||
|
||||
// GetFlags returns a map of feature flag -> bool. DB setting takes precedence
|
||||
// and falls back to environment variables if present.
|
||||
func (h *FeatureFlagsHandler) GetFlags(c *gin.Context) {
|
||||
// Phase 0: Performance instrumentation
|
||||
startTime := time.Now()
|
||||
defer func() {
|
||||
latency := time.Since(startTime).Milliseconds()
|
||||
log.Printf("[METRICS] GET /feature-flags: %dms", latency)
|
||||
}()
|
||||
|
||||
result := make(map[string]bool)
|
||||
|
||||
// Phase 1: Batch query optimization - fetch all flags in single query (eliminating N+1)
|
||||
var settings []models.Setting
|
||||
if err := h.DB.Where("key IN ?", defaultFlags).Find(&settings).Error; err != nil {
|
||||
log.Printf("[ERROR] Failed to fetch feature flags: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch feature flags"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build map for O(1) lookup
|
||||
settingsMap := make(map[string]models.Setting)
|
||||
for _, s := range settings {
|
||||
settingsMap[s.Key] = s
|
||||
}
|
||||
|
||||
// Process all flags using the map
|
||||
for _, key := range defaultFlags {
|
||||
defaultVal := true
|
||||
if v, ok := defaultFlagValues[key]; ok {
|
||||
defaultVal = v
|
||||
}
|
||||
|
||||
if key == "feature.notifications.legacy.fallback_enabled" {
|
||||
result[key] = h.resolveRetiredLegacyFallback(settingsMap)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if flag exists in DB
|
||||
if s, exists := settingsMap[key]; exists {
|
||||
v := strings.ToLower(strings.TrimSpace(s.Value))
|
||||
b := v == "1" || v == "true" || v == "yes"
|
||||
result[key] = b
|
||||
continue
|
||||
}
|
||||
|
||||
// Fallback to env vars. Try FEATURE_... and also stripped service name e.g. CERBERUS_ENABLED
|
||||
envKey := strings.ToUpper(strings.ReplaceAll(key, ".", "_"))
|
||||
if ev, ok := os.LookupEnv(envKey); ok {
|
||||
if bv, err := strconv.ParseBool(ev); err == nil {
|
||||
result[key] = bv
|
||||
continue
|
||||
}
|
||||
// accept 1/0
|
||||
result[key] = ev == "1"
|
||||
continue
|
||||
}
|
||||
|
||||
// Try shorter variant after removing leading "feature."
|
||||
if strings.HasPrefix(key, "feature.") {
|
||||
short := strings.ToUpper(strings.ReplaceAll(strings.TrimPrefix(key, "feature."), ".", "_"))
|
||||
if ev, ok := os.LookupEnv(short); ok {
|
||||
if bv, err := strconv.ParseBool(ev); err == nil {
|
||||
result[key] = bv
|
||||
continue
|
||||
}
|
||||
result[key] = ev == "1"
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Default based on declared flag value
|
||||
result[key] = defaultVal
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func parseFlagBool(raw string) (bool, bool) {
|
||||
v := strings.ToLower(strings.TrimSpace(raw))
|
||||
switch v {
|
||||
case "1", "true", "yes":
|
||||
return true, true
|
||||
case "0", "false", "no":
|
||||
return false, true
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
func (h *FeatureFlagsHandler) resolveRetiredLegacyFallback(settingsMap map[string]models.Setting) bool {
|
||||
const retiredKey = "feature.notifications.legacy.fallback_enabled"
|
||||
|
||||
if s, exists := settingsMap[retiredKey]; exists {
|
||||
if _, ok := parseFlagBool(s.Value); !ok {
|
||||
log.Printf("[WARN] Invalid persisted retired fallback flag value, forcing disabled: key=%s value=%q", retiredKey, s.Value)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
for _, alias := range retiredLegacyFallbackEnvAliases {
|
||||
if ev, ok := os.LookupEnv(alias); ok {
|
||||
if _, parsed := parseFlagBool(ev); !parsed {
|
||||
log.Printf("[WARN] Invalid environment retired fallback flag value, forcing disabled: key=%s value=%q", alias, ev)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// UpdateFlags accepts a JSON object map[string]bool and upserts settings.
|
||||
func (h *FeatureFlagsHandler) UpdateFlags(c *gin.Context) {
|
||||
// Phase 0: Performance instrumentation
|
||||
startTime := time.Now()
|
||||
defer func() {
|
||||
latency := time.Since(startTime).Milliseconds()
|
||||
log.Printf("[METRICS] PUT /feature-flags: %dms", latency)
|
||||
}()
|
||||
|
||||
var payload map[string]bool
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if v, exists := payload["feature.notifications.legacy.fallback_enabled"]; exists && v {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "feature.notifications.legacy.fallback_enabled is retired and can only be false",
|
||||
"code": "LEGACY_FALLBACK_REMOVED",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 1: Transaction wrapping - all updates in single atomic transaction
|
||||
if err := h.DB.Transaction(func(tx *gorm.DB) error {
|
||||
for k, v := range payload {
|
||||
// Only allow keys in the default list to avoid arbitrary settings
|
||||
allowed := false
|
||||
for _, ak := range defaultFlags {
|
||||
if ak == k {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
continue
|
||||
}
|
||||
|
||||
if k == "feature.notifications.legacy.fallback_enabled" {
|
||||
v = false
|
||||
}
|
||||
|
||||
s := models.Setting{Key: k, Value: strconv.FormatBool(v), Type: "bool", Category: "feature"}
|
||||
if err := tx.Where(models.Setting{Key: k}).Assign(s).FirstOrCreate(&s).Error; err != nil {
|
||||
return err // Rollback on error
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
log.Printf("[ERROR] Failed to update feature flags: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update feature flags"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
Reference in New Issue
Block a user