package handlers import ( "context" "errors" "fmt" "net/http" "strconv" "strings" "time" "github.com/gin-gonic/gin" "gorm.io/gorm" "github.com/Wikid82/charon/backend/internal/logger" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/security" "github.com/Wikid82/charon/backend/internal/services" "github.com/Wikid82/charon/backend/internal/utils" ) // CaddyConfigManager interface for triggering Caddy config reload type CaddyConfigManager interface { ApplyConfig(ctx context.Context) error } // CacheInvalidator interface for invalidating security settings cache type CacheInvalidator interface { InvalidateCache() } type SettingsHandler struct { DB *gorm.DB MailService *services.MailService CaddyManager CaddyConfigManager // For triggering config reload on security settings change Cerberus CacheInvalidator // For invalidating cache on security settings change SecuritySvc *services.SecurityService DataRoot string } const ( settingCaddyKeepaliveIdle = "caddy.keepalive_idle" settingCaddyKeepaliveCount = "caddy.keepalive_count" minCaddyKeepaliveIdleDuration = time.Second maxCaddyKeepaliveIdleDuration = 24 * time.Hour minCaddyKeepaliveCount = 1 maxCaddyKeepaliveCount = 100 ) func NewSettingsHandler(db *gorm.DB) *SettingsHandler { return &SettingsHandler{ DB: db, MailService: services.NewMailService(db), } } // NewSettingsHandlerWithDeps creates a SettingsHandler with all dependencies for config reload func NewSettingsHandlerWithDeps(db *gorm.DB, caddyMgr CaddyConfigManager, cerberus CacheInvalidator, securitySvc *services.SecurityService, dataRoot string) *SettingsHandler { return &SettingsHandler{ DB: db, MailService: services.NewMailService(db), CaddyManager: caddyMgr, Cerberus: cerberus, SecuritySvc: securitySvc, DataRoot: dataRoot, } } // GetSettings returns all settings. func (h *SettingsHandler) GetSettings(c *gin.Context) { var settings []models.Setting if err := h.DB.Find(&settings).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"}) return } // Convert to map for easier frontend consumption settingsMap := make(map[string]any) for _, s := range settings { if isSensitiveSettingKey(s.Key) { hasSecret := strings.TrimSpace(s.Value) != "" settingsMap[s.Key] = "********" settingsMap[s.Key+".has_secret"] = hasSecret settingsMap[s.Key+".last_updated"] = s.UpdatedAt.UTC().Format(time.RFC3339) continue } settingsMap[s.Key] = s.Value } c.JSON(http.StatusOK, settingsMap) } func isSensitiveSettingKey(key string) bool { normalizedKey := strings.ToLower(strings.TrimSpace(key)) sensitiveFragments := []string{ "password", "secret", "token", "api_key", "apikey", "webhook", } for _, fragment := range sensitiveFragments { if strings.Contains(normalizedKey, fragment) { return true } } return false } type UpdateSettingRequest struct { Key string `json:"key" binding:"required"` Value string `json:"value" binding:"required"` Category string `json:"category"` Type string `json:"type"` } // UpdateSetting updates or creates a setting. func (h *SettingsHandler) UpdateSetting(c *gin.Context) { if !requireAdmin(c) { return } var req UpdateSettingRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Key == "security.admin_whitelist" { if err := validateAdminWhitelist(req.Value); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid admin_whitelist: %v", err)}) return } } if err := validateOptionalKeepaliveSetting(req.Key, req.Value); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } setting := models.Setting{ Key: req.Key, Value: req.Value, } if req.Category != "" { setting.Category = req.Category } if req.Type != "" { setting.Type = req.Type } // Upsert if err := h.DB.Where(models.Setting{Key: req.Key}).Assign(setting).FirstOrCreate(&setting).Error; err != nil { if respondPermissionError(c, h.SecuritySvc, "settings_save_failed", err, h.DataRoot) { return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save setting"}) return } if req.Key == "security.acl.enabled" && strings.EqualFold(strings.TrimSpace(req.Value), "true") { cerberusSetting := models.Setting{ Key: "feature.cerberus.enabled", Value: "true", Category: "feature", Type: "bool", } if err := h.DB.Where(models.Setting{Key: cerberusSetting.Key}).Assign(cerberusSetting).FirstOrCreate(&cerberusSetting).Error; err != nil { if respondPermissionError(c, h.SecuritySvc, "settings_save_failed", err, h.DataRoot) { return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable Cerberus"}) return } legacyCerberus := models.Setting{ Key: "security.cerberus.enabled", Value: "true", Category: "security", Type: "bool", } if err := h.DB.Where(models.Setting{Key: legacyCerberus.Key}).Assign(legacyCerberus).FirstOrCreate(&legacyCerberus).Error; err != nil { if respondPermissionError(c, h.SecuritySvc, "settings_save_failed", err, h.DataRoot) { return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable Cerberus"}) return } if err := h.ensureSecurityConfigEnabled(); err != nil { if respondPermissionError(c, h.SecuritySvc, "settings_save_failed", err, h.DataRoot) { return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable security config"}) return } } if req.Key == "security.admin_whitelist" { if err := h.syncAdminWhitelist(req.Value); err != nil { if errors.Is(err, services.ErrInvalidAdminCIDR) { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid admin_whitelist"}) return } if respondPermissionError(c, h.SecuritySvc, "settings_save_failed", err, h.DataRoot) { return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update security config"}) return } } // Trigger cache invalidation and config reload for security settings if strings.HasPrefix(req.Key, "security.") { // Invalidate Cerberus cache immediately so middleware uses new settings if h.Cerberus != nil { h.Cerberus.InvalidateCache() } // Trigger sync Caddy config reload so callers can rely on deterministic applied state if h.CaddyManager != nil { ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) defer cancel() if err := h.CaddyManager.ApplyConfig(ctx); err != nil { logger.Log().WithError(err).Warn("Failed to reload Caddy config after security setting change") c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reload configuration"}) return } logger.Log().WithField("setting_key", sanitizeForLog(req.Key)).Info("Caddy config reloaded after security setting change") } } c.JSON(http.StatusOK, setting) } // PatchConfig updates multiple configuration settings at once // PATCH /api/v1/config // Requires admin authentication func (h *SettingsHandler) PatchConfig(c *gin.Context) { if !requireAdmin(c) { return } // Parse nested configuration structure var configUpdates map[string]interface{} if err := c.ShouldBindJSON(&configUpdates); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Flatten nested configuration into key-value pairs // Example: {"security": {"admin_whitelist": "..."}} -> "security.admin_whitelist": "..." updates := make(map[string]string) flattenConfig(configUpdates, "", updates) adminWhitelist, hasAdminWhitelist := updates["security.admin_whitelist"] aclEnabled := false if value, ok := updates["security.acl.enabled"]; ok && strings.EqualFold(value, "true") { aclEnabled = true updates["feature.cerberus.enabled"] = "true" } if err := h.DB.Transaction(func(tx *gorm.DB) error { for key, value := range updates { if key == "security.admin_whitelist" { if err := validateAdminWhitelist(value); err != nil { return fmt.Errorf("invalid admin_whitelist: %w", err) } } if err := validateOptionalKeepaliveSetting(key, value); err != nil { return err } setting := models.Setting{ Key: key, Value: value, Category: strings.Split(key, ".")[0], Type: "string", } if err := tx.Where(models.Setting{Key: key}).Assign(setting).FirstOrCreate(&setting).Error; err != nil { return fmt.Errorf("save setting %s: %w", key, err) } } if hasAdminWhitelist { if err := h.syncAdminWhitelistWithDB(tx, adminWhitelist); err != nil { return err } } if aclEnabled { if err := h.ensureSecurityConfigEnabledWithDB(tx); err != nil { return err } } return nil }); err != nil { if errors.Is(err, services.ErrInvalidAdminCIDR) || strings.Contains(err.Error(), "invalid admin_whitelist") { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid admin_whitelist"}) return } if strings.Contains(err.Error(), "invalid caddy.keepalive_idle") || strings.Contains(err.Error(), "invalid caddy.keepalive_count") { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if respondPermissionError(c, h.SecuritySvc, "settings_save_failed", err, h.DataRoot) { return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save settings"}) return } // Trigger cache invalidation and Caddy reload for security settings needsReload := false for key := range updates { if strings.HasPrefix(key, "security.") { needsReload = true break } } if needsReload { // Invalidate Cerberus cache if h.Cerberus != nil { h.Cerberus.InvalidateCache() } // Trigger sync Caddy config reload so callers can rely on deterministic applied state if h.CaddyManager != nil { ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) defer cancel() if err := h.CaddyManager.ApplyConfig(ctx); err != nil { logger.Log().WithError(err).Warn("Failed to reload Caddy config after security settings change") c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reload configuration"}) return } logger.Log().Info("Caddy config reloaded after security settings change") } } // Return current config state var settings []models.Setting if err := h.DB.Find(&settings).Error; err != nil { if respondPermissionError(c, h.SecuritySvc, "settings_save_failed", err, h.DataRoot) { return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch updated config"}) return } // Convert to map for response settingsMap := make(map[string]string) for _, s := range settings { settingsMap[s.Key] = s.Value } c.JSON(http.StatusOK, settingsMap) } func (h *SettingsHandler) ensureSecurityConfigEnabled() error { return h.ensureSecurityConfigEnabledWithDB(h.DB) } func (h *SettingsHandler) ensureSecurityConfigEnabledWithDB(db *gorm.DB) error { var cfg models.SecurityConfig err := db.Where("name = ?", "default").First(&cfg).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { cfg = models.SecurityConfig{Name: "default", Enabled: true} return db.Create(&cfg).Error } return err } if cfg.Enabled { return nil } return db.Model(&cfg).Update("enabled", true).Error } // flattenConfig converts nested map to flat key-value pairs with dot notation func flattenConfig(config map[string]interface{}, prefix string, result map[string]string) { for k, v := range config { key := k if prefix != "" { key = prefix + "." + k } switch value := v.(type) { case map[string]interface{}: flattenConfig(value, key, result) case string: result[key] = value default: result[key] = fmt.Sprintf("%v", value) } } } // validateAdminWhitelist validates IP CIDR format func validateAdminWhitelist(whitelist string) error { if whitelist == "" { return nil // Empty is valid (no whitelist) } cidrs := strings.Split(whitelist, ",") for _, cidr := range cidrs { cidr = strings.TrimSpace(cidr) if cidr == "" { continue } // Basic CIDR validation (simple check, more thorough validation happens in security middleware) if !strings.Contains(cidr, "/") { return fmt.Errorf("invalid CIDR format: %s (must include /prefix)", cidr) } } return nil } func validateOptionalKeepaliveSetting(key, value string) error { switch key { case settingCaddyKeepaliveIdle: return validateKeepaliveIdleValue(value) case settingCaddyKeepaliveCount: return validateKeepaliveCountValue(value) default: return nil } } func validateKeepaliveIdleValue(value string) error { idle := strings.TrimSpace(value) if idle == "" { return nil } d, err := time.ParseDuration(idle) if err != nil { return fmt.Errorf("invalid caddy.keepalive_idle") } if d < minCaddyKeepaliveIdleDuration || d > maxCaddyKeepaliveIdleDuration { return fmt.Errorf("invalid caddy.keepalive_idle") } return nil } func validateKeepaliveCountValue(value string) error { raw := strings.TrimSpace(value) if raw == "" { return nil } count, err := strconv.Atoi(raw) if err != nil { return fmt.Errorf("invalid caddy.keepalive_count") } if count < minCaddyKeepaliveCount || count > maxCaddyKeepaliveCount { return fmt.Errorf("invalid caddy.keepalive_count") } return nil } func (h *SettingsHandler) syncAdminWhitelist(whitelist string) error { return h.syncAdminWhitelistWithDB(h.DB, whitelist) } func (h *SettingsHandler) syncAdminWhitelistWithDB(db *gorm.DB, whitelist string) error { securitySvc := services.NewSecurityService(db) cfg, err := securitySvc.Get() if err != nil { if err != services.ErrSecurityConfigNotFound { return err } cfg = &models.SecurityConfig{Name: "default"} } if cfg.Name == "" { cfg.Name = "default" } cfg.AdminWhitelist = whitelist return securitySvc.Upsert(cfg) } // SMTPConfigRequest represents the request body for SMTP configuration. type SMTPConfigRequest struct { Host string `json:"host" binding:"required"` Port int `json:"port" binding:"required,min=1,max=65535"` Username string `json:"username"` Password string `json:"password"` FromAddress string `json:"from_address" binding:"required,email"` Encryption string `json:"encryption" binding:"required,oneof=none ssl starttls"` } // GetSMTPConfig returns the current SMTP configuration. func (h *SettingsHandler) GetSMTPConfig(c *gin.Context) { if !requireAdmin(c) { return } config, err := h.MailService.GetSMTPConfig() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch SMTP configuration"}) return } // Don't expose the password c.JSON(http.StatusOK, gin.H{ "host": config.Host, "port": config.Port, "username": config.Username, "password": MaskPassword(config.Password), "from_address": config.FromAddress, "encryption": config.Encryption, "configured": config.Host != "" && config.FromAddress != "", }) } // MaskPassword masks the password for display. func MaskPassword(password string) string { if password == "" { return "" } return "********" } // MaskPasswordForTest is an alias for testing. func MaskPasswordForTest(password string) string { return MaskPassword(password) } // UpdateSMTPConfig updates the SMTP configuration. func (h *SettingsHandler) UpdateSMTPConfig(c *gin.Context) { if !requireAdmin(c) { return } var req SMTPConfigRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // If password is masked (i.e., unchanged), keep the existing password existingConfig, _ := h.MailService.GetSMTPConfig() if req.Password == "********" || req.Password == "" { req.Password = existingConfig.Password } config := &services.SMTPConfig{ Host: req.Host, Port: req.Port, Username: req.Username, Password: req.Password, FromAddress: req.FromAddress, Encryption: req.Encryption, } if err := h.MailService.SaveSMTPConfig(config); err != nil { if respondPermissionError(c, h.SecuritySvc, "smtp_save_failed", err, h.DataRoot) { return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save SMTP configuration: " + err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "SMTP configuration saved successfully"}) } // TestSMTPConfig tests the SMTP connection. func (h *SettingsHandler) TestSMTPConfig(c *gin.Context) { if !requireAdmin(c) { return } if err := h.MailService.TestConnection(); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "success": false, "error": err.Error(), }) return } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "SMTP connection successful", }) } // SendTestEmail sends a test email to verify the SMTP configuration. func (h *SettingsHandler) SendTestEmail(c *gin.Context) { if !requireAdmin(c) { return } type TestEmailRequest struct { To string `json:"to" binding:"required,email"` } var req TestEmailRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } htmlBody := `
If you received this email, your SMTP configuration is working correctly!
This is an automated test email.