Ctrl K
ComunidadGuías…

server/internal/handlers/admin_config.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package handlers

import (
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"errors"
	"log"
	"net/http"
	"net/url"
	"strings"
	"time"

	"blackbox/server/internal/incidents"
	mcppkg "blackbox/server/internal/mcp"
	"blackbox/server/internal/models"
	"gorm.io/gorm"
)

const aiProviderKey = "ai_provider"
const aiURLKey = "ai_url"
const aiModelKey = "ai_model"
const aiAPIKeyKey = "ai_api_key"
const aiModeKey = "ai_mode"
const baseURLKey = "base_url"
const mcpEnabledKey = "mcp_enabled"
const mcpAuthTokenKey = "mcp_auth_token"
const mcpMigrationWarningAcknowledgedKey = "mcp_migration_warning_acknowledged"
const legacyMCPPortKey = "mcp_port"

// Legacy keys — read-only fallback for existing installs
const legacyOllamaURLKey = "ollama_url"
const legacyOllamaModelKey = "ollama_model"
const legacyOllamaModeKey = "ollama_mode"

type aiSettingsRequest struct {
	Provider    string `json:"ai_provider"`
	URL         string `json:"ai_url"`
	Model       string `json:"ai_model"`
	APIKey      string `json:"ai_api_key"`
	ClearAPIKey bool   `json:"ai_clear_api_key"`
	Mode        string `json:"ai_mode"`
}

type normalizedAISettings struct {
	provider string
	url      string
	model    string
	apiKey   string
	mode     string
}

func AdminConfig(db *gorm.DB, webhookSecret string, mcpMgr *mcppkg.MCPManager) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		redactSecrets, err := getFileWatcherRedactSecrets(db)
		if err != nil {
			writeError(w, http.StatusInternalServerError, "failed to load admin config")
			return
		}
		ai, err := getAISettings(db)
		if err != nil {
			writeError(w, http.StatusInternalServerError, "failed to load admin config")
			return
		}
		var baseURLSetting models.AppSetting
		baseURL := ""
		if err := db.First(&baseURLSetting, "key = ?", baseURLKey).Error; err == nil {
			baseURL = strings.TrimSpace(baseURLSetting.Value)
		}
		mcpSettings, err := getMCPSettings(db)
		if err != nil {
			writeError(w, http.StatusInternalServerError, "failed to load admin config")
			return
		}
		mcpMigrationWarning, err := shouldShowMCPMigrationWarning(db)
		if err != nil {
			writeError(w, http.StatusInternalServerError, "failed to load admin config")
			return
		}
		mcpRunning := false
		if mcpMgr != nil {
			mcpRunning = mcpMgr.IsRunning()
		}

		w.Header().Set("Content-Type", "application/json")
		w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
		w.Header().Set("Pragma", "no-cache")
		w.Header().Set("Expires", "0")
		if err := json.NewEncoder(w).Encode(map[string]any{
			"webhook_secret":              webhookSecret,
			"file_watcher_redact_secrets": redactSecrets,
			"ai_provider":                 ai.provider,
			"ai_url":                      ai.url,
			"ai_model":                    ai.model,
			"ai_api_key_set":              ai.apiKeySet,
			"ai_mode":                     ai.mode,
			"base_url":                    baseURL,
			"mcp_enabled":                 mcpSettings.enabled,
			"mcp_auth_token_set":          mcpSettings.tokenSet,
			"mcp_auth_token_suffix":       mcpSettings.tokenSuffix,
			"mcp_running":                 mcpRunning,
			"mcp_endpoint_url":            mcpEndpointURL(baseURL),
			"mcp_migration_warning":       mcpMigrationWarning,
		}); err != nil {
			log.Printf("AdminConfig encode: %v", err)
		}
	}
}

type mcpSettingsRequest struct {
	Enabled                        *bool `json:"mcp_enabled"`
	AcknowledgeMCPMigrationWarning bool  `json:"acknowledge_mcp_migration_warning"`
}

func UpdateMCPSettings(db *gorm.DB, mcpMgr *mcppkg.MCPManager) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var req mcpSettingsRequest
		if !decodeJSONBody(w, r, 1<<20, &req) {
			return
		}

		if req.Enabled == nil && req.AcknowledgeMCPMigrationWarning {
			if err := db.Save(&models.AppSetting{
				Key:       mcpMigrationWarningAcknowledgedKey,
				Value:     "true",
				UpdatedAt: time.Now(),
			}).Error; err != nil {
				writeError(w, http.StatusInternalServerError, "failed to save mcp settings")
				return
			}
			w.WriteHeader(http.StatusNoContent)
			return
		}
		if req.Enabled == nil {
			writeError(w, http.StatusBadRequest, "mcp_enabled is required unless acknowledging the migration warning")
			return
		}

		existing, err := getMCPSettings(db)
		if err != nil {
			writeError(w, http.StatusInternalServerError, "failed to load mcp settings")
			return
		}
		enabled := *req.Enabled
		token := existing.token
		if enabled && token == "" {
			token, err = generateMCPToken()
			if err != nil {
				writeError(w, http.StatusInternalServerError, "failed to generate token")
				return
			}
		}
		if enabled && token == "" {
			writeError(w, http.StatusBadRequest, "mcp_auth_token is required")
			return
		}

		// DB is the source of truth: write first, apply second.
		now := time.Now()
		settings := []models.AppSetting{
			{Key: mcpEnabledKey, Value: boolToString(enabled), UpdatedAt: now},
		}
		if token != "" {
			settings = append(settings, models.AppSetting{Key: mcpAuthTokenKey, Value: token, UpdatedAt: now})
		}
		if req.AcknowledgeMCPMigrationWarning {
			settings = append(settings, models.AppSetting{
				Key:       mcpMigrationWarningAcknowledgedKey,
				Value:     "true",
				UpdatedAt: now,
			})
		}
		if err := db.Transaction(func(tx *gorm.DB) error {
			for _, s := range settings {
				if err := tx.Save(&s).Error; err != nil {
					return err
				}
			}
			return nil
		}); err != nil {
			writeError(w, http.StatusInternalServerError, "failed to save mcp settings")
			return
		}

		// Apply live — DB is already consistent; a failure here is a soft error.
		// The next Blackbox restart will self-correct from DB.
		if mcpMgr != nil {
			if err := mcpMgr.ApplySettings(enabled, token); err != nil {
				log.Printf("mcp: ApplySettings failed after DB write: %v", err)
				writeError(w, http.StatusInternalServerError,
					"settings saved but failed to apply: "+err.Error()+" — restart Blackbox to apply")
				return
			}
		}
		w.WriteHeader(http.StatusNoContent)
	}
}

func RegenerateMCPToken(db *gorm.DB, mcpMgr *mcppkg.MCPManager) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		newToken, err := generateMCPToken()
		if err != nil {
			writeError(w, http.StatusInternalServerError, "failed to generate token")
			return
		}
		if err := db.Save(&models.AppSetting{Key: mcpAuthTokenKey, Value: newToken, UpdatedAt: time.Now()}).Error; err != nil {
			writeError(w, http.StatusInternalServerError, "failed to save token")
			return
		}

		mcpCfg, err := getMCPSettings(db)
		if err != nil {
			writeError(w, http.StatusInternalServerError, "failed to load mcp settings")
			return
		}
		if mcpCfg.enabled && mcpMgr != nil {
			if err := mcpMgr.ApplySettings(true, newToken); err != nil {
				writeError(w, http.StatusInternalServerError, "failed to restart mcp server with new token")
				return
			}
		}

		w.Header().Set("Content-Type", "application/json")
		if err := json.NewEncoder(w).Encode(map[string]string{"mcp_auth_token_suffix": tokenSuffix(newToken)}); err != nil {
			log.Printf("RegenerateMCPToken encode: %v", err)
		}
	}
}

func UpdateBaseURLSetting(db *gorm.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var req struct {
			BaseURL string `json:"base_url"`
		}
		if !decodeJSONBody(w, r, 4<<10, &req) {
			return
		}

		value := normalizeBaseURL(req.BaseURL)
		if strings.TrimSpace(req.BaseURL) != "" && value == "" {
			writeError(w, http.StatusBadRequest, "base_url must be a valid absolute URL or empty")
			return
		}

		setting := models.AppSetting{Key: baseURLKey, Value: value, UpdatedAt: time.Now()}
		if err := db.Save(&setting).Error; err != nil {
			writeError(w, http.StatusInternalServerError, "failed to save base_url")
			return
		}
		w.WriteHeader(http.StatusNoContent)
	}
}

func UpdateAISettings(db *gorm.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var req aiSettingsRequest
		if !decodeJSONBody(w, r, 1<<20, &req) {
			return
		}

		normalized, err := normalizeAISettingsRequest(db, req)
		if err != nil {
			if errors.Is(err, errLoadExistingAPIKey) {
				writeError(w, http.StatusInternalServerError, err.Error())
			} else {
				writeError(w, http.StatusBadRequest, err.Error())
			}
			return
		}

		now := time.Now()
		appSettings := []models.AppSetting{
			{Key: aiProviderKey, Value: normalized.provider, UpdatedAt: now},
			{Key: aiURLKey, Value: normalized.url, UpdatedAt: now},
			{Key: aiModelKey, Value: normalized.model, UpdatedAt: now},
			{Key: aiAPIKeyKey, Value: normalized.apiKey, UpdatedAt: now},
			{Key: aiModeKey, Value: normalized.mode, UpdatedAt: now},
		}
		if err := db.Transaction(func(tx *gorm.DB) error {
			for _, s := range appSettings {
				if err := tx.Save(&s).Error; err != nil {
					return err
				}
			}
			return nil
		}); err != nil {
			writeError(w, http.StatusInternalServerError, "failed to save setting")
			return
		}

		w.WriteHeader(http.StatusNoContent)
	}
}

func TestAISettings(db *gorm.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var req aiSettingsRequest
		if !decodeJSONBody(w, r, 1<<20, &req) {
			return
		}

		settings, err := normalizeAISettingsRequest(db, req)
		if err != nil {
			if errors.Is(err, errLoadExistingAPIKey) {
				writeError(w, http.StatusInternalServerError, err.Error())
			} else {
				writeError(w, http.StatusBadRequest, err.Error())
			}
			return
		}
		if settings.url == "" {
			writeError(w, http.StatusBadRequest, "ai_url is required")
			return
		}
		if settings.model == "" {
			writeError(w, http.StatusBadRequest, "ai_model is required")
			return
		}

		result, err := incidents.GenerateWithConfig(
			r.Context(),
			settings.provider,
			settings.url,
			settings.model,
			settings.apiKey,
			"Reply with the single word OK.",
			15*time.Second,
		)
		if err != nil {
			writeError(w, http.StatusBadGateway, err.Error())
			return
		}

		result = strings.TrimSpace(result)
		resultRunes := []rune(result)
		if len(resultRunes) > 200 {
			result = string(resultRunes[:200]) + "..."
		}

		w.Header().Set("Content-Type", "application/json")
		w.Header().Set("Cache-Control", "no-store")
		if err := json.NewEncoder(w).Encode(map[string]any{
			"ok":       true,
			"response": result,
		}); err != nil {
			log.Printf("TestAISettings encode: %v", err)
		}
	}
}

// UpdateOllamaSettingsLegacy handles PUT /api/admin/settings/ollama (deprecated).
// Translates legacy ollama_* field names to ai_* and saves using the new schema,
// always preserving any stored API key since the old endpoint had no key concept.
func UpdateOllamaSettingsLegacy(db *gorm.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var req struct {
			OllamaURL   string `json:"ollama_url"`
			OllamaModel string `json:"ollama_model"`
			OllamaMode  string `json:"ollama_mode"`
		}
		if !decodeJSONBody(w, r, 1<<20, &req) {
			return
		}

		aiURL := strings.TrimSpace(req.OllamaURL)
		if aiURL != "" {
			parsed, err := url.ParseRequestURI(aiURL)
			if err != nil || parsed.Scheme == "" || parsed.Host == "" {
				writeError(w, http.StatusBadRequest, "ollama_url must be a valid absolute URL")
				return
			}
		}

		mode := strings.TrimSpace(req.OllamaMode)
		if mode != "" && mode != "analysis" && mode != "enhanced" {
			writeError(w, http.StatusBadRequest, "ollama_mode must be 'analysis' or 'enhanced'")
			return
		}
		if mode == "" {
			mode = "analysis"
		}

		// Preserve any existing API key — legacy endpoint has no key field.
		var apiKey string
		var existing models.AppSetting
		if err := db.First(&existing, "key = ?", aiAPIKeyKey).Error; err == nil {
			apiKey = existing.Value
		} else if !errors.Is(err, gorm.ErrRecordNotFound) {
			writeError(w, http.StatusInternalServerError, "failed to load existing api key")
			return
		}

		now := time.Now()
		settings := []models.AppSetting{
			{Key: aiProviderKey, Value: "ollama", UpdatedAt: now},
			{Key: aiURLKey, Value: aiURL, UpdatedAt: now},
			{Key: aiModelKey, Value: strings.TrimSpace(req.OllamaModel), UpdatedAt: now},
			{Key: aiAPIKeyKey, Value: apiKey, UpdatedAt: now},
			{Key: aiModeKey, Value: mode, UpdatedAt: now},
		}
		if err := db.Transaction(func(tx *gorm.DB) error {
			for _, s := range settings {
				if err := tx.Save(&s).Error; err != nil {
					return err
				}
			}
			return nil
		}); err != nil {
			writeError(w, http.StatusInternalServerError, "failed to save setting")
			return
		}

		w.WriteHeader(http.StatusNoContent)
	}
}

type aiSettingsResult struct {
	provider  string
	url       string
	model     string
	apiKeySet bool
	mode      string
}

type mcpSettingsResult struct {
	enabled     bool
	tokenSet    bool
	tokenSuffix string
	token       string
}

func getMCPSettings(db *gorm.DB) (mcpSettingsResult, error) {
	keys := []string{mcpEnabledKey, mcpAuthTokenKey}
	var settings []models.AppSetting
	if err := db.Where("key IN ?", keys).Find(&settings).Error; err != nil {
		return mcpSettingsResult{}, err
	}
	m := make(map[string]string, len(settings))
	for _, s := range settings {
		m[s.Key] = strings.TrimSpace(s.Value)
	}

	result := mcpSettingsResult{
		enabled: m[mcpEnabledKey] == "true",
		token:   m[mcpAuthTokenKey],
	}
	result.tokenSet = result.token != ""
	result.tokenSuffix = tokenSuffix(result.token)
	return result, nil
}

func shouldShowMCPMigrationWarning(db *gorm.DB) (bool, error) {
	var legacyPortCount int64
	if err := db.Model(&models.AppSetting{}).Where("key = ?", legacyMCPPortKey).Count(&legacyPortCount).Error; err != nil {
		return false, err
	}
	if legacyPortCount == 0 {
		return false, nil
	}

	var ackSetting models.AppSetting
	if err := db.First(&ackSetting, "key = ?", mcpMigrationWarningAcknowledgedKey).Error; err == nil {
		return strings.TrimSpace(ackSetting.Value) != "true", nil
	} else if errors.Is(err, gorm.ErrRecordNotFound) {
		return true, nil
	} else {
		return false, err
	}
}

func mcpEndpointURL(baseURL string) string {
	baseURL = normalizeBaseURL(baseURL)
	if baseURL == "" {
		return ""
	}
	return baseURL + "/mcp"
}

func normalizeBaseURL(baseURL string) string {
	baseURL = strings.TrimSpace(baseURL)
	if baseURL == "" {
		return ""
	}

	parsed, err := url.ParseRequestURI(baseURL)
	if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") ||
		parsed.RawQuery != "" || parsed.Fragment != "" {
		return ""
	}

	return strings.TrimRight(baseURL, "/")
}

func boolToString(v bool) string {
	if v {
		return "true"
	}
	return "false"
}

func generateMCPToken() (string, error) {
	b := make([]byte, 32)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	return hex.EncodeToString(b), nil
}

func tokenSuffix(token string) string {
	if len(token) < 8 {
		return ""
	}
	return token[len(token)-8:]
}

func getAISettings(db *gorm.DB) (aiSettingsResult, error) {
	keys := []string{aiProviderKey, aiURLKey, aiModelKey, aiAPIKeyKey, aiModeKey, legacyOllamaURLKey, legacyOllamaModelKey, legacyOllamaModeKey}
	var settings []models.AppSetting
	if err := db.Where("key IN ?", keys).Find(&settings).Error; err != nil {
		return aiSettingsResult{}, err
	}

	m := make(map[string]string, len(settings))
	for _, s := range settings {
		m[s.Key] = strings.TrimSpace(s.Value)
	}

	result := aiSettingsResult{mode: "analysis"}

	result.provider = m[aiProviderKey]
	if result.provider == "" {
		result.provider = "ollama"
	}

	if url, ok := m[aiURLKey]; ok {
		result.url = url
	} else {
		result.url = m[legacyOllamaURLKey]
	}

	if model, ok := m[aiModelKey]; ok {
		result.model = model
	} else {
		result.model = m[legacyOllamaModelKey]
	}

	result.apiKeySet = m[aiAPIKeyKey] != ""

	if _, ok := m[aiModeKey]; ok {
		if v := m[aiModeKey]; v != "" {
			result.mode = v
		}
	} else if v := m[legacyOllamaModeKey]; v != "" {
		result.mode = v
	}

	return result, nil
}

var errLoadExistingAPIKey = errors.New("failed to load existing api key")

func normalizeAISettingsRequest(db *gorm.DB, req aiSettingsRequest) (normalizedAISettings, error) {
	provider := strings.TrimSpace(req.Provider)
	if provider == "" {
		provider = "ollama"
	}
	if provider != "ollama" && provider != "openai_compat" {
		return normalizedAISettings{}, errors.New("ai_provider must be 'ollama' or 'openai_compat'")
	}

	aiURL := strings.TrimSpace(req.URL)
	if aiURL != "" {
		parsed, err := url.ParseRequestURI(aiURL)
		if err != nil || parsed.Scheme == "" || parsed.Host == "" {
			return normalizedAISettings{}, errors.New("ai_url must be a valid absolute URL")
		}
	}

	mode := strings.TrimSpace(req.Mode)
	if mode != "" && mode != "analysis" && mode != "enhanced" {
		return normalizedAISettings{}, errors.New("ai_mode must be 'analysis' or 'enhanced'")
	}
	if mode == "" {
		mode = "analysis"
	}

	apiKey := strings.TrimSpace(req.APIKey)
	if apiKey == "" && !req.ClearAPIKey {
		var existing models.AppSetting
		if err := db.First(&existing, "key = ?", aiAPIKeyKey).Error; err == nil {
			apiKey = existing.Value
		} else if !errors.Is(err, gorm.ErrRecordNotFound) {
			return normalizedAISettings{}, errLoadExistingAPIKey
		}
	}

	return normalizedAISettings{
		provider: provider,
		url:      aiURL,
		model:    strings.TrimSpace(req.Model),
		apiKey:   apiKey,
		mode:     mode,
	}, nil
}