package management import ( "encoding/json" "errors" "fmt" "net/http" "os" "sort" "strconv" "strings" "github.com/gin-gonic/gin" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/htmlsanitize" "github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost" "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" "gopkg.in/yaml.v3" ) type pluginListResponse struct { PluginsEnabled bool `json:"plugins_enabled"` PluginsDir string `json:"plugins_dir"` Plugins []pluginListEntry `json:"plugins"` } type pluginListEntry struct { ID string `json:"id"` Path string `json:"path"` Configured bool `json:"configured"` Registered bool `json:"registered"` Enabled bool `json:"enabled"` EffectiveEnabled bool `json:"effective_enabled"` SupportsOAuth bool `json:"supports_oauth"` OAuthProvider string `json:"oauth_provider"` Logo string `json:"logo"` ConfigFields []pluginConfigFieldInfo `json:"config_fields"` Menus []pluginMenuInfo `json:"menus"` Metadata *pluginMetadataInfo `json:"metadata"` } type pluginMetadataInfo struct { Name string `json:"name"` Version string `json:"version"` Author string `json:"author"` GitHubRepository string `json:"github_repository"` Logo string `json:"logo"` ConfigFields []pluginConfigFieldInfo `json:"config_fields"` } type pluginConfigFieldInfo struct { Name string `json:"name"` Type string `json:"type"` EnumValues []string `json:"enum_values"` Description string `json:"description"` } type pluginMenuInfo struct { Path string `json:"path"` Menu string `json:"menu"` Description string `json:"description"` } // ListPlugins returns discovered, configured, and registered plugin entries. func (h *Handler) ListPlugins(c *gin.Context) { if h == nil || h.cfg == nil { c.JSON(http.StatusOK, pluginListResponse{ PluginsDir: "plugins", Plugins: []pluginListEntry{}, }) return } h.mu.Lock() pluginsEnabled := h.cfg.Plugins.Enabled pluginsDir := normalizedPluginsDir(h.cfg.Plugins.Dir) configs := make(map[string]config.PluginInstanceConfig, len(h.cfg.Plugins.Configs)) for id, item := range h.cfg.Plugins.Configs { configs[id] = item } host := h.pluginHost h.mu.Unlock() resolvedPluginsDir, errResolvePluginsDir := config.ResolvePluginsDir(pluginsDir) if errResolvePluginsDir != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_directory_invalid", "message": errResolvePluginsDir.Error()}) return } pluginsDir = resolvedPluginsDir entries := make(map[string]pluginListEntry) files, errDiscover := pluginhost.DiscoverPluginFiles(pluginsDir, pluginStoreDesiredVersions(configs)) if errDiscover != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_discovery_failed", "message": errDiscover.Error()}) return } for _, file := range files { entries[file.ID] = pluginListEntry{ ID: htmlsanitize.String(file.ID), Path: htmlsanitize.String(file.Path), Enabled: false, ConfigFields: []pluginConfigFieldInfo{}, Menus: []pluginMenuInfo{}, } } for id, item := range configs { entry := entries[id] entry.ID = htmlsanitize.String(id) entry.Configured = true entry.Enabled = pluginInstanceEnabled(item) if entry.ConfigFields == nil { entry.ConfigFields = []pluginConfigFieldInfo{} } if entry.Menus == nil { entry.Menus = []pluginMenuInfo{} } entries[id] = entry } if host != nil { for _, info := range host.RegisteredPlugins() { entry := entries[info.ID] entry.ID = htmlsanitize.String(info.ID) entry.Registered = true entry.SupportsOAuth = info.SupportsOAuth entry.OAuthProvider = htmlsanitize.String(info.OAuthProvider) entry.Logo = htmlsanitize.String(info.Metadata.Logo) entry.ConfigFields = pluginConfigFields(info.Metadata.ConfigFields) entry.Menus = pluginMenus(info.Menus) entry.Metadata = pluginMetadata(info.Metadata) entries[info.ID] = entry } } ids := make([]string, 0, len(entries)) for id := range entries { ids = append(ids, id) } sort.Strings(ids) out := make([]pluginListEntry, 0, len(ids)) for _, id := range ids { entry := entries[id] entry.EffectiveEnabled = pluginsEnabled && entry.Enabled && entry.Registered if entry.ConfigFields == nil { entry.ConfigFields = []pluginConfigFieldInfo{} } if entry.Menus == nil { entry.Menus = []pluginMenuInfo{} } out = append(out, entry) } c.JSON(http.StatusOK, pluginListResponse{ PluginsEnabled: pluginsEnabled, PluginsDir: htmlsanitize.String(pluginsDir), Plugins: out, }) } // GetPluginConfig returns the preserved plugins.configs. object as JSON. func (h *Handler) GetPluginConfig(c *gin.Context) { id, okID := pluginIDFromRequest(c) if !okID { return } if h == nil { c.JSON(http.StatusNotFound, gin.H{"error": "plugin_not_found", "message": "plugin not found"}) return } h.mu.Lock() if h.cfg == nil { h.mu.Unlock() c.JSON(http.StatusNotFound, gin.H{"error": "plugin_not_found", "message": "plugin not found"}) return } item, configured := h.cfg.Plugins.Configs[id] pluginsDir := normalizedPluginsDir(h.cfg.Plugins.Dir) host := h.pluginHost h.mu.Unlock() if configured { body, errBody := pluginConfigJSONObject(item) if errBody != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_config_encode_failed", "message": errBody.Error()}) return } c.JSON(http.StatusOK, body) return } if pluginRegistered(host, id) { c.JSON(http.StatusOK, gin.H{}) return } resolvedPluginsDir, errResolvePluginsDir := config.ResolvePluginsDir(pluginsDir) if errResolvePluginsDir != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_directory_invalid", "message": errResolvePluginsDir.Error()}) return } discovered, errDiscover := pluginDiscovered(resolvedPluginsDir, id) if errDiscover != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_discovery_failed", "message": errDiscover.Error()}) return } if discovered { c.JSON(http.StatusOK, gin.H{}) return } c.JSON(http.StatusNotFound, gin.H{"error": "plugin_not_found", "message": "plugin not found"}) } // PatchPluginEnabled updates plugins.configs..enabled without touching plugins.enabled. func (h *Handler) PatchPluginEnabled(c *gin.Context) { id, okID := pluginIDFromRequest(c) if !okID { return } var body struct { Enabled *bool `json:"enabled"` } if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil || body.Enabled == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_body", "message": "enabled is required"}) return } h.mu.Lock() ensurePluginConfigMap(h.cfg) item := h.cfg.Plugins.Configs[id] node := pluginConfigNode(item) setYAMLMappingValue(node, "enabled", boolYAMLNode(*body.Enabled)) updated, errConfig := pluginInstanceConfigFromNode(node) if errConfig != nil { h.mu.Unlock() c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_config", "message": errConfig.Error()}) return } h.cfg.Plugins.Configs[id] = updated cfgSnapshot, okSnapshot := h.saveConfigAndSnapshotLocked(c) h.mu.Unlock() if !okSnapshot { return } h.reloadConfigAfterManagementSaveAsync(c.Request.Context(), cfgSnapshot) c.JSON(http.StatusOK, gin.H{"status": "ok"}) } // PutPluginConfig replaces plugins.configs. with the request object. func (h *Handler) PutPluginConfig(c *gin.Context) { id, okID := pluginIDFromRequest(c) if !okID { return } body, okBody := readPluginConfigObject(c) if !okBody { return } node, errNode := yamlNodeFromJSONObject(body) if errNode != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_body", "message": errNode.Error()}) return } updated, errConfig := pluginInstanceConfigFromNode(node) if errConfig != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_config", "message": errConfig.Error()}) return } h.mu.Lock() defer h.mu.Unlock() ensurePluginConfigMap(h.cfg) h.cfg.Plugins.Configs[id] = updated h.persistLocked(c) } // PatchPluginConfig shallow-merges plugins.configs. with the request object. func (h *Handler) PatchPluginConfig(c *gin.Context) { id, okID := pluginIDFromRequest(c) if !okID { return } body, okBody := readPluginConfigObject(c) if !okBody { return } h.mu.Lock() defer h.mu.Unlock() ensurePluginConfigMap(h.cfg) node := pluginConfigNode(h.cfg.Plugins.Configs[id]) keys := make([]string, 0, len(body)) for key := range body { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { value := body[key] if value == nil { deleteYAMLMappingKey(node, key) continue } valueNode, errNode := yamlNodeFromJSONValue(value) if errNode != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_body", "message": errNode.Error()}) return } setYAMLMappingValue(node, key, valueNode) } updated, errConfig := pluginInstanceConfigFromNode(node) if errConfig != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_config", "message": errConfig.Error()}) return } h.cfg.Plugins.Configs[id] = updated h.persistLocked(c) } // DeletePlugin removes the selected local plugin file and its saved config. func (h *Handler) DeletePlugin(c *gin.Context) { id, okID := pluginIDFromRequest(c) if !okID { return } if h == nil { c.JSON(http.StatusNotFound, gin.H{"error": "plugin_not_found", "message": "plugin not found"}) return } h.mu.Lock() if h.cfg == nil { h.mu.Unlock() c.JSON(http.StatusNotFound, gin.H{"error": "plugin_not_found", "message": "plugin not found"}) return } pluginsDir := normalizedPluginsDir(h.cfg.Plugins.Dir) item, configured := h.cfg.Plugins.Configs[id] host := h.pluginHost h.mu.Unlock() resolvedPluginsDir, errResolvePluginsDir := config.ResolvePluginsDir(pluginsDir) if errResolvePluginsDir != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_directory_invalid", "message": errResolvePluginsDir.Error()}) return } pluginsDir = resolvedPluginsDir var desiredVersions map[string]string if configured { desiredVersions = pluginStoreDesiredVersions(map[string]config.PluginInstanceConfig{id: item}) } path, errPath := pluginFilePath(pluginsDir, id, desiredVersions) if errPath != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_discovery_failed", "message": errPath.Error()}) return } if path == "" && !configured { c.JSON(http.StatusNotFound, gin.H{"error": "plugin_not_found", "message": "plugin not found"}) return } if pluginBusy(host, id) && (host == nil || !host.UnloadPlugin(id)) && pluginBusy(host, id) { c.JSON(http.StatusConflict, gin.H{ "error": "plugin_delete_requires_restart", "message": "loaded plugin cannot be deleted while the server is running", "restart_required": true, }) return } fileDeleted := false if path != "" { if errRemove := os.Remove(path); errRemove != nil { if !errors.Is(errRemove, os.ErrNotExist) { c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_delete_failed", "message": errRemove.Error()}) return } } else { fileDeleted = true } } h.mu.Lock() delete(h.cfg.Plugins.Configs, id) if configured { if errSave := config.SaveConfigPreserveComments(h.configFilePath, h.cfg); errSave != nil { h.mu.Unlock() c.JSON(http.StatusInternalServerError, gin.H{ "error": "config_save_failed", "message": fmt.Sprintf("plugin deleted but saving config failed: %s", errSave.Error()), "file_deleted": fileDeleted, "path": path, }) return } } cfgSnapshot := h.reloadSnapshotConfigLocked() h.mu.Unlock() h.reloadConfigAfterManagementSaveAsync(c.Request.Context(), cfgSnapshot) c.JSON(http.StatusOK, gin.H{ "status": "deleted", "id": htmlsanitize.String(id), "path": htmlsanitize.String(path), "file_deleted": fileDeleted, "configured_removed": configured, "restart_required": false, }) } func normalizedPluginsDir(dir string) string { dir = strings.TrimSpace(dir) if dir == "" { return "plugins" } return dir } func pluginInstanceEnabled(item config.PluginInstanceConfig) bool { if item.Enabled == nil { return false } return *item.Enabled } func pluginRegistered(host *pluginhost.Host, id string) bool { if host == nil { return false } for _, info := range host.RegisteredPlugins() { if info.ID == id { return true } } return false } func pluginDiscovered(pluginsDir string, id string) (bool, error) { files, errDiscover := pluginhost.DiscoverPluginFiles(pluginsDir) if errDiscover != nil { return false, errDiscover } for _, file := range files { if file.ID == id { return true, nil } } return false, nil } func pluginFilePath(pluginsDir string, id string, desiredVersions ...map[string]string) (string, error) { files, errDiscover := pluginhost.DiscoverPluginFiles(pluginsDir, desiredVersions...) if errDiscover != nil { return "", errDiscover } for _, file := range files { if file.ID == id { return file.Path, nil } } return "", nil } func pluginConfigFields(fields []pluginapi.ConfigField) []pluginConfigFieldInfo { out := make([]pluginConfigFieldInfo, 0, len(fields)) for _, field := range fields { out = append(out, pluginConfigFieldInfo{ Name: htmlsanitize.String(field.Name), Type: htmlsanitize.String(string(field.Type)), EnumValues: htmlsanitize.Strings(field.EnumValues), Description: htmlsanitize.String(field.Description), }) } return out } func pluginMenus(menus []pluginhost.RegisteredPluginMenu) []pluginMenuInfo { out := make([]pluginMenuInfo, 0, len(menus)) for _, menu := range menus { out = append(out, pluginMenuInfo{ Path: htmlsanitize.String(menu.Path), Menu: htmlsanitize.String(menu.Menu), Description: htmlsanitize.String(menu.Description), }) } return out } func pluginMetadata(meta pluginapi.Metadata) *pluginMetadataInfo { return &pluginMetadataInfo{ Name: htmlsanitize.String(meta.Name), Version: htmlsanitize.String(meta.Version), Author: htmlsanitize.String(meta.Author), GitHubRepository: htmlsanitize.String(meta.GitHubRepository), Logo: htmlsanitize.String(meta.Logo), ConfigFields: pluginConfigFields(meta.ConfigFields), } } func pluginIDFromRequest(c *gin.Context) (string, bool) { id := strings.TrimSpace(c.Param("id")) if !pluginhost.ValidatePluginID(id) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_plugin_id", "message": "invalid plugin id"}) return "", false } return id, true } func readPluginConfigObject(c *gin.Context) (map[string]any, bool) { decoder := json.NewDecoder(c.Request.Body) decoder.UseNumber() var body map[string]any if errDecode := decoder.Decode(&body); errDecode != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_body", "message": errDecode.Error()}) return nil, false } if body == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_body", "message": "body must be a JSON object"}) return nil, false } return body, true } func ensurePluginConfigMap(cfg *config.Config) { if cfg == nil { return } cfg.NormalizePluginsConfig() } func pluginConfigNode(item config.PluginInstanceConfig) *yaml.Node { if item.Raw.Kind == yaml.MappingNode { return cloneYAMLNode(&item.Raw) } node := emptyYAMLMappingNode() if item.Enabled != nil { setYAMLMappingValue(node, "enabled", boolYAMLNode(*item.Enabled)) } if item.Priority != 0 { setYAMLMappingValue(node, "priority", intYAMLNode(item.Priority)) } return node } func pluginConfigJSONObject(item config.PluginInstanceConfig) (map[string]any, error) { value, errValue := yamlNodeToJSONValue(pluginConfigNode(item)) if errValue != nil { return nil, errValue } body, ok := value.(map[string]any) if !ok || body == nil { return map[string]any{}, nil } return body, nil } func pluginInstanceConfigFromNode(node *yaml.Node) (config.PluginInstanceConfig, error) { if node == nil { node = emptyYAMLMappingNode() } var item config.PluginInstanceConfig if errDecode := node.Decode(&item); errDecode != nil { return config.PluginInstanceConfig{}, errDecode } return item, nil } func yamlNodeFromJSONObject(body map[string]any) (*yaml.Node, error) { node := emptyYAMLMappingNode() keys := make([]string, 0, len(body)) for key := range body { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { valueNode, errNode := yamlNodeFromJSONValue(body[key]) if errNode != nil { return nil, fmt.Errorf("%s: %w", key, errNode) } setYAMLMappingValue(node, key, valueNode) } return node, nil } func yamlNodeFromJSONValue(value any) (*yaml.Node, error) { switch typed := value.(type) { case nil: return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}, nil case string: return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: typed}, nil case bool: return boolYAMLNode(typed), nil case json.Number: if _, errInt64 := typed.Int64(); errInt64 == nil { return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: typed.String()}, nil } if _, errFloat64 := typed.Float64(); errFloat64 == nil { return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!float", Value: typed.String()}, nil } return nil, fmt.Errorf("invalid number %q", typed.String()) case float64: return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!float", Value: strconv.FormatFloat(typed, 'f', -1, 64)}, nil case []any: node := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} for _, item := range typed { child, errChild := yamlNodeFromJSONValue(item) if errChild != nil { return nil, errChild } node.Content = append(node.Content, child) } return node, nil case map[string]any: return yamlNodeFromJSONObject(typed) default: return nil, fmt.Errorf("unsupported value type %T", value) } } func yamlNodeToJSONValue(node *yaml.Node) (any, error) { if node == nil { return nil, nil } switch node.Kind { case yaml.MappingNode: out := make(map[string]any, len(node.Content)/2) for index := 0; index+1 < len(node.Content); index += 2 { key := node.Content[index] value := node.Content[index+1] if key == nil { continue } child, errChild := yamlNodeToJSONValue(value) if errChild != nil { return nil, fmt.Errorf("%s: %w", key.Value, errChild) } out[key.Value] = child } return out, nil case yaml.SequenceNode: out := make([]any, 0, len(node.Content)) for _, childNode := range node.Content { child, errChild := yamlNodeToJSONValue(childNode) if errChild != nil { return nil, errChild } out = append(out, child) } return out, nil case yaml.ScalarNode: if node.Tag == "!!str" || node.Tag == "" { return node.Value, nil } var value any if errDecode := node.Decode(&value); errDecode != nil { return nil, errDecode } return value, nil case yaml.AliasNode: return yamlNodeToJSONValue(node.Alias) default: return nil, fmt.Errorf("unsupported YAML node kind %d", node.Kind) } } func emptyYAMLMappingNode() *yaml.Node { return &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} } func boolYAMLNode(value bool) *yaml.Node { return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: strconv.FormatBool(value)} } func intYAMLNode(value int) *yaml.Node { return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: strconv.Itoa(value)} } func setYAMLMappingValue(mapping *yaml.Node, key string, value *yaml.Node) { if mapping.Kind != yaml.MappingNode { *mapping = *emptyYAMLMappingNode() } for index := 0; index+1 < len(mapping.Content); index += 2 { if mapping.Content[index] != nil && mapping.Content[index].Value == key { mapping.Content[index+1] = value return } } mapping.Content = append(mapping.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, value) } func deleteYAMLMappingKey(mapping *yaml.Node, key string) { if mapping == nil || mapping.Kind != yaml.MappingNode { return } for index := 0; index+1 < len(mapping.Content); index += 2 { if mapping.Content[index] != nil && mapping.Content[index].Value == key { mapping.Content = append(mapping.Content[:index], mapping.Content[index+2:]...) return } } } func cloneYAMLNode(node *yaml.Node) *yaml.Node { if node == nil { return nil } out := *node if len(node.Content) > 0 { out.Content = make([]*yaml.Node, 0, len(node.Content)) for _, child := range node.Content { out.Content = append(out.Content, cloneYAMLNode(child)) } } return &out }