diff --git a/internal/api/handlers/management/config_basic.go b/internal/api/handlers/management/config_basic.go index 6e3c9f67..0f052fbd 100644 --- a/internal/api/handlers/management/config_basic.go +++ b/internal/api/handlers/management/config_basic.go @@ -1,13 +1,126 @@ package management import ( + "io" + "net/http" + "os" + "path/filepath" + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "gopkg.in/yaml.v3" ) func (h *Handler) GetConfig(c *gin.Context) { c.JSON(200, h.cfg) } +func (h *Handler) GetConfigYAML(c *gin.Context) { + data, err := os.ReadFile(h.configFilePath) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "read_failed", "message": err.Error()}) + return + } + var node yaml.Node + if err := yaml.Unmarshal(data, &node); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "parse_failed", "message": err.Error()}) + return + } + c.Header("Content-Type", "application/yaml; charset=utf-8") + c.Header("Vary", "format, Accept") + enc := yaml.NewEncoder(c.Writer) + enc.SetIndent(2) + _ = enc.Encode(&node) + _ = enc.Close() +} + +func WriteConfig(path string, data []byte) error { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return err + } + if _, err := f.Write(data); err != nil { + f.Close() + return err + } + if err := f.Sync(); err != nil { + f.Close() + return err + } + return f.Close() +} + +func (h *Handler) PutConfigYAML(c *gin.Context) { + body, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_yaml", "message": "cannot read request body"}) + return + } + var cfg config.Config + if err := yaml.Unmarshal(body, &cfg); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_yaml", "message": err.Error()}) + return + } + // Validate config using LoadConfigOptional with optional=false to enforce parsing + tmpDir := filepath.Dir(h.configFilePath) + tmpFile, err := os.CreateTemp(tmpDir, "config-validate-*.yaml") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "write_failed", "message": err.Error()}) + return + } + tempFile := tmpFile.Name() + if _, err := tmpFile.Write(body); err != nil { + tmpFile.Close() + os.Remove(tempFile) + c.JSON(http.StatusInternalServerError, gin.H{"error": "write_failed", "message": err.Error()}) + return + } + if err := tmpFile.Close(); err != nil { + os.Remove(tempFile) + c.JSON(http.StatusInternalServerError, gin.H{"error": "write_failed", "message": err.Error()}) + return + } + defer os.Remove(tempFile) + _, err = config.LoadConfigOptional(tempFile, false) + if err != nil { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid_config", "message": err.Error()}) + return + } + h.mu.Lock() + defer h.mu.Unlock() + if WriteConfig(h.configFilePath, body) != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "write_failed", "message": "failed to write config"}) + return + } + // Reload into handler to keep memory in sync + newCfg, err := config.LoadConfig(h.configFilePath) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "reload_failed", "message": err.Error()}) + return + } + h.cfg = newCfg + c.JSON(http.StatusOK, gin.H{"ok": true, "changed": []string{"config"}}) +} + +// GetConfigFile returns the raw config.yaml file bytes without re-encoding. +// It preserves comments and original formatting/styles. +func (h *Handler) GetConfigFile(c *gin.Context) { + data, err := os.ReadFile(h.configFilePath) + if err != nil { + if os.IsNotExist(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "not_found", "message": "config file not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "read_failed", "message": err.Error()}) + return + } + c.Header("Content-Type", "application/yaml; charset=utf-8") + c.Header("Cache-Control", "no-store") + c.Header("X-Content-Type-Options", "nosniff") + // Write raw bytes as-is + _, _ = c.Writer.Write(data) +} + // Debug func (h *Handler) GetDebug(c *gin.Context) { c.JSON(200, gin.H{"debug": h.cfg.Debug}) } func (h *Handler) PutDebug(c *gin.Context) { h.updateBoolField(c, func(v bool) { h.cfg.Debug = v }) } diff --git a/internal/api/server.go b/internal/api/server.go index 6c8915ca..79b1b4ba 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -384,6 +384,8 @@ func (s *Server) registerManagementRoutes() { { mgmt.GET("/usage", s.mgmt.GetUsageStatistics) mgmt.GET("/config", s.mgmt.GetConfig) + mgmt.PUT("/config.yaml", s.mgmt.PutConfigYAML) + mgmt.GET("/config.yaml", s.mgmt.GetConfigFile) mgmt.GET("/debug", s.mgmt.GetDebug) mgmt.PUT("/debug", s.mgmt.PutDebug)