Files
CLIProxyAPI/internal/api/handlers/management/config_basic.go

244 lines
7.5 KiB
Go

package management
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
)
const (
latestReleaseURL = "https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/latest"
latestReleaseUserAgent = "CLIProxyAPI"
)
func (h *Handler) GetConfig(c *gin.Context) {
if h == nil || h.cfg == nil {
c.JSON(200, gin.H{})
return
}
cfgCopy := *h.cfg
c.JSON(200, &cfgCopy)
}
type releaseInfo struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
}
// GetLatestVersion returns the latest release version from GitHub without downloading assets.
func (h *Handler) GetLatestVersion(c *gin.Context) {
client := &http.Client{Timeout: 10 * time.Second}
proxyURL := ""
if h != nil && h.cfg != nil {
proxyURL = strings.TrimSpace(h.cfg.ProxyURL)
}
if proxyURL != "" {
sdkCfg := &sdkconfig.SDKConfig{ProxyURL: proxyURL}
util.SetProxy(sdkCfg, client)
}
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, latestReleaseURL, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "request_create_failed", "message": err.Error()})
return
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", latestReleaseUserAgent)
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "request_failed", "message": err.Error()})
return
}
defer func() {
if errClose := resp.Body.Close(); errClose != nil {
log.WithError(errClose).Debug("failed to close latest version response body")
}
}()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
c.JSON(http.StatusBadGateway, gin.H{"error": "unexpected_status", "message": fmt.Sprintf("status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))})
return
}
var info releaseInfo
if errDecode := json.NewDecoder(resp.Body).Decode(&info); errDecode != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "decode_failed", "message": errDecode.Error()})
return
}
version := strings.TrimSpace(info.TagName)
if version == "" {
version = strings.TrimSpace(info.Name)
}
if version == "" {
c.JSON(http.StatusBadGateway, gin.H{"error": "invalid_response", "message": "missing release version"})
return
}
c.JSON(http.StatusOK, gin.H{"latest-version": version})
}
func WriteConfig(path string, data []byte) error {
data = config.NormalizeCommentIndentation(data)
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return err
}
if _, errWrite := f.Write(data); errWrite != nil {
_ = f.Close()
return errWrite
}
if errSync := f.Sync(); errSync != nil {
_ = f.Close()
return errSync
}
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 _, errWrite := tmpFile.Write(body); errWrite != nil {
_ = tmpFile.Close()
_ = os.Remove(tempFile)
c.JSON(http.StatusInternalServerError, gin.H{"error": "write_failed", "message": errWrite.Error()})
return
}
if errClose := tmpFile.Close(); errClose != nil {
_ = os.Remove(tempFile)
c.JSON(http.StatusInternalServerError, gin.H{"error": "write_failed", "message": errClose.Error()})
return
}
defer func() {
_ = 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"}})
}
// GetConfigYAML returns the raw config.yaml file bytes without re-encoding.
// It preserves comments and original formatting/styles.
func (h *Handler) GetConfigYAML(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 }) }
// UsageStatisticsEnabled
func (h *Handler) GetUsageStatisticsEnabled(c *gin.Context) {
c.JSON(200, gin.H{"usage-statistics-enabled": h.cfg.UsageStatisticsEnabled})
}
func (h *Handler) PutUsageStatisticsEnabled(c *gin.Context) {
h.updateBoolField(c, func(v bool) { h.cfg.UsageStatisticsEnabled = v })
}
// UsageStatisticsEnabled
func (h *Handler) GetLoggingToFile(c *gin.Context) {
c.JSON(200, gin.H{"logging-to-file": h.cfg.LoggingToFile})
}
func (h *Handler) PutLoggingToFile(c *gin.Context) {
h.updateBoolField(c, func(v bool) { h.cfg.LoggingToFile = v })
}
// Request log
func (h *Handler) GetRequestLog(c *gin.Context) { c.JSON(200, gin.H{"request-log": h.cfg.RequestLog}) }
func (h *Handler) PutRequestLog(c *gin.Context) {
h.updateBoolField(c, func(v bool) { h.cfg.RequestLog = v })
}
// Websocket auth
func (h *Handler) GetWebsocketAuth(c *gin.Context) {
c.JSON(200, gin.H{"ws-auth": h.cfg.WebsocketAuth})
}
func (h *Handler) PutWebsocketAuth(c *gin.Context) {
h.updateBoolField(c, func(v bool) { h.cfg.WebsocketAuth = v })
}
// Request retry
func (h *Handler) GetRequestRetry(c *gin.Context) {
c.JSON(200, gin.H{"request-retry": h.cfg.RequestRetry})
}
func (h *Handler) PutRequestRetry(c *gin.Context) {
h.updateIntField(c, func(v int) { h.cfg.RequestRetry = v })
}
// Max retry interval
func (h *Handler) GetMaxRetryInterval(c *gin.Context) {
c.JSON(200, gin.H{"max-retry-interval": h.cfg.MaxRetryInterval})
}
func (h *Handler) PutMaxRetryInterval(c *gin.Context) {
h.updateIntField(c, func(v int) { h.cfg.MaxRetryInterval = v })
}
// Proxy URL
func (h *Handler) GetProxyURL(c *gin.Context) { c.JSON(200, gin.H{"proxy-url": h.cfg.ProxyURL}) }
func (h *Handler) PutProxyURL(c *gin.Context) {
h.updateStringField(c, func(v string) { h.cfg.ProxyURL = v })
}
func (h *Handler) DeleteProxyURL(c *gin.Context) {
h.cfg.ProxyURL = ""
h.persist(c)
}