mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-10 01:51:30 +08:00
Add plugin delete management endpoint
This commit is contained in:
@@ -2,8 +2,10 @@ package management
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -297,6 +299,87 @@ func (h *Handler) PatchPluginConfig(c *gin.Context) {
|
||||
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)
|
||||
_, configured := h.cfg.Plugins.Configs[id]
|
||||
host := h.pluginHost
|
||||
h.mu.Unlock()
|
||||
|
||||
path, errPath := pluginFilePath(pluginsDir, id)
|
||||
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 pluginLoaded(host, id) && (host == nil || !host.UnloadPlugin(id)) && pluginLoaded(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
|
||||
}
|
||||
}
|
||||
reloadCfg := h.cfg
|
||||
h.mu.Unlock()
|
||||
|
||||
h.reloadConfigAfterManagementSave(c.Request.Context(), reloadCfg)
|
||||
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 == "" {
|
||||
@@ -337,6 +420,19 @@ func pluginDiscovered(pluginsDir string, id string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func pluginFilePath(pluginsDir string, id string) (string, error) {
|
||||
files, errDiscover := pluginhost.DiscoverPluginFiles(pluginsDir)
|
||||
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 {
|
||||
|
||||
@@ -2,6 +2,7 @@ package management
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"html"
|
||||
"net/http"
|
||||
@@ -325,6 +326,80 @@ func TestPatchPluginConfigMergesAndDeletesFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePluginRemovesDiscoveredFileAndConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
pluginsDir := writeManagementPluginFile(t, "sample")
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
Plugins: config.PluginsConfig{
|
||||
Dir: pluginsDir,
|
||||
Configs: map[string]config.PluginInstanceConfig{
|
||||
"sample": pluginConfigFromYAML(t, "enabled: true\nmode: safe\n"),
|
||||
},
|
||||
},
|
||||
},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
}
|
||||
reloads := 0
|
||||
h.SetConfigReloadHook(func(_ context.Context, cfg *config.Config) {
|
||||
reloads++
|
||||
if cfg != h.cfg {
|
||||
t.Fatalf("reload config = %p, want handler config %p", cfg, h.cfg)
|
||||
}
|
||||
})
|
||||
|
||||
path, errPath := pluginFilePath(pluginsDir, "sample")
|
||||
if errPath != nil {
|
||||
t.Fatalf("pluginFilePath() error = %v", errPath)
|
||||
}
|
||||
if path == "" {
|
||||
t.Fatal("plugin path is empty")
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Params = gin.Params{{Key: "id", Value: "sample"}}
|
||||
c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/plugins/sample", nil)
|
||||
|
||||
h.DeletePlugin(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if _, ok := h.cfg.Plugins.Configs["sample"]; ok {
|
||||
t.Fatal("plugin config still exists after delete")
|
||||
}
|
||||
if _, errStat := os.Stat(path); !os.IsNotExist(errStat) {
|
||||
t.Fatalf("plugin file stat error = %v, want not exist", errStat)
|
||||
}
|
||||
if reloads != 1 {
|
||||
t.Fatalf("reloads = %d, want 1", reloads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePluginReturnsNotFoundForUnknownPlugin(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := &Handler{
|
||||
cfg: &config.Config{},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Params = gin.Params{{Key: "id", Value: "missing"}}
|
||||
c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/plugins/missing", nil)
|
||||
|
||||
h.DeletePlugin(c)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusNotFound, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginDisplayFieldsEscapeHTML(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -627,6 +627,7 @@ func (s *Server) registerManagementRoutes() {
|
||||
mgmt.GET("/plugins", s.mgmt.ListPlugins)
|
||||
mgmt.GET("/plugin-store", s.mgmt.ListPluginStore)
|
||||
mgmt.POST("/plugin-store/:id/install", s.mgmt.InstallPluginFromStore)
|
||||
mgmt.DELETE("/plugins/:id", s.mgmt.DeletePlugin)
|
||||
mgmt.PATCH("/plugins/:id/enabled", s.mgmt.PatchPluginEnabled)
|
||||
mgmt.GET("/plugins/:id/config", s.mgmt.GetPluginConfig)
|
||||
mgmt.PUT("/plugins/:id/config", s.mgmt.PutPluginConfig)
|
||||
|
||||
@@ -216,6 +216,9 @@ func TestManagementPluginsRouteRegistered(t *testing.T) {
|
||||
server.cfg.Plugins.Configs = map[string]proxyconfig.PluginInstanceConfig{
|
||||
"sample": {Enabled: &enabled, Priority: 4},
|
||||
}
|
||||
if errWrite := os.WriteFile(server.configFilePath, []byte("{}\n"), 0o600); errWrite != nil {
|
||||
t.Fatalf("failed to write config file: %v", errWrite)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v0/management/plugins", nil)
|
||||
req.Header.Set("Authorization", "Bearer test-management-key")
|
||||
@@ -254,6 +257,14 @@ func TestManagementPluginsRouteRegistered(t *testing.T) {
|
||||
if !configPayload.Enabled || configPayload.Priority != 4 {
|
||||
t.Fatalf("plugin config = %#v, want enabled true priority 4", configPayload)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodDelete, "/v0/management/plugins/sample", nil)
|
||||
req.Header.Set("Authorization", "Bearer test-management-key")
|
||||
rr = httptest.NewRecorder()
|
||||
server.engine.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("delete status = %d, want %d body=%s", rr.Code, http.StatusOK, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeEnabledHidesManagementEndpointsAndControlPanel(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user