mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-06-17 23:33:49 +08:00
Merge pull request #3848 from router-for-me/feat/Community_Plugins
Feat/community plugins
This commit is contained in:
@@ -61,6 +61,9 @@ pprof:
|
||||
plugins:
|
||||
enabled: false
|
||||
dir: "plugins"
|
||||
# Additional plugin store registries. The built-in official registry is always included.
|
||||
# store-sources:
|
||||
# - "https://example.com/cliproxy-plugins/registry.json"
|
||||
configs:
|
||||
example:
|
||||
enabled: true
|
||||
|
||||
@@ -37,10 +37,29 @@ type pluginReleaseCacheEntry struct {
|
||||
type pluginStoreListResponse struct {
|
||||
PluginsEnabled bool `json:"plugins_enabled"`
|
||||
PluginsDir string `json:"plugins_dir"`
|
||||
Sources []pluginStoreSource `json:"sources"`
|
||||
SourceErrors []pluginStoreSourceErr `json:"source_errors,omitempty"`
|
||||
Plugins []pluginStoreListEntry `json:"plugins"`
|
||||
}
|
||||
|
||||
type pluginStoreSource struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type pluginStoreSourceErr struct {
|
||||
SourceID string `json:"source_id"`
|
||||
SourceName string `json:"source_name"`
|
||||
SourceURL string `json:"source_url"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type pluginStoreListEntry struct {
|
||||
StoreID string `json:"store_id"`
|
||||
SourceID string `json:"source_id"`
|
||||
SourceName string `json:"source_name"`
|
||||
SourceURL string `json:"source_url"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
@@ -63,6 +82,9 @@ type pluginStoreListEntry struct {
|
||||
|
||||
type pluginInstallResponse struct {
|
||||
Status string `json:"status"`
|
||||
SourceID string `json:"source_id"`
|
||||
SourceName string `json:"source_name"`
|
||||
SourceURL string `json:"source_url"`
|
||||
ID string `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Path string `json:"path"`
|
||||
@@ -80,12 +102,21 @@ type pluginLocalStatus struct {
|
||||
EffectiveEnabled bool
|
||||
}
|
||||
|
||||
type sourcedPlugin struct {
|
||||
source pluginstore.Source
|
||||
plugin pluginstore.Plugin
|
||||
}
|
||||
|
||||
func (h *Handler) ListPluginStore(c *gin.Context) {
|
||||
pluginsEnabled, pluginsDir, proxyURL, configs, host := h.pluginStoreSnapshot()
|
||||
client := h.newPluginStoreClient(proxyURL)
|
||||
registry, errRegistry := client.FetchRegistry(c.Request.Context())
|
||||
if errRegistry != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_store_registry_failed", "message": errRegistry.Error()})
|
||||
pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, configs, host := h.pluginStoreSnapshot()
|
||||
sources, errSources := h.pluginStoreSources(sourceConfigs)
|
||||
if errSources != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_store_source_invalid", "message": errSources.Error()})
|
||||
return
|
||||
}
|
||||
plugins, sourceErrors := h.fetchSourcedPlugins(c.Request.Context(), proxyURL, sources)
|
||||
if len(plugins) == 0 && len(sourceErrors) > 0 {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_store_registry_failed", "message": sourceErrors[0].Message})
|
||||
return
|
||||
}
|
||||
statuses, errStatus := pluginLocalStatuses(pluginsEnabled, pluginsDir, configs, host)
|
||||
@@ -94,10 +125,16 @@ func (h *Handler) ListPluginStore(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
latestVersions := h.latestPluginVersions(c.Request.Context(), client, registry.Plugins)
|
||||
latestInput := make([]pluginstore.Plugin, 0, len(plugins))
|
||||
for _, item := range plugins {
|
||||
latestInput = append(latestInput, item.plugin)
|
||||
}
|
||||
client := h.newPluginStoreClient(proxyURL, "")
|
||||
latestVersions := h.latestPluginVersions(c.Request.Context(), client, latestInput)
|
||||
|
||||
entries := make([]pluginStoreListEntry, 0, len(registry.Plugins))
|
||||
for index, plugin := range registry.Plugins {
|
||||
entries := make([]pluginStoreListEntry, 0, len(plugins))
|
||||
for index, item := range plugins {
|
||||
plugin := item.plugin
|
||||
status := statuses[plugin.ID]
|
||||
installedVersion := status.InstalledVersion
|
||||
// Fall back to the registry version when the latest release is unknown.
|
||||
@@ -106,6 +143,10 @@ func (h *Handler) ListPluginStore(c *gin.Context) {
|
||||
storeVersion = latestVersions[index]
|
||||
}
|
||||
entries = append(entries, pluginStoreListEntry{
|
||||
StoreID: htmlsanitize.String(item.source.ID + "/" + plugin.ID),
|
||||
SourceID: htmlsanitize.String(item.source.ID),
|
||||
SourceName: htmlsanitize.String(item.source.Name),
|
||||
SourceURL: htmlsanitize.String(item.source.URL),
|
||||
ID: htmlsanitize.String(plugin.ID),
|
||||
Name: htmlsanitize.String(plugin.Name),
|
||||
Description: htmlsanitize.String(plugin.Description),
|
||||
@@ -130,6 +171,8 @@ func (h *Handler) ListPluginStore(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, pluginStoreListResponse{
|
||||
PluginsEnabled: pluginsEnabled,
|
||||
PluginsDir: htmlsanitize.String(pluginsDir),
|
||||
Sources: sanitizePluginStoreSources(sources),
|
||||
SourceErrors: sanitizePluginStoreSourceErrors(sourceErrors),
|
||||
Plugins: entries,
|
||||
})
|
||||
}
|
||||
@@ -144,16 +187,14 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) {
|
||||
return
|
||||
}
|
||||
installCtx := c.Request.Context()
|
||||
pluginsEnabled, pluginsDir, proxyURL, _, host := h.pluginStoreSnapshot()
|
||||
client := h.newPluginStoreClient(proxyURL)
|
||||
registry, errRegistry := client.FetchRegistry(installCtx)
|
||||
if errRegistry != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_store_registry_failed", "message": errRegistry.Error()})
|
||||
pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, _, host := h.pluginStoreSnapshot()
|
||||
sources, errSources := h.pluginStoreSources(sourceConfigs)
|
||||
if errSources != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_store_source_invalid", "message": errSources.Error()})
|
||||
return
|
||||
}
|
||||
plugin, okPlugin := registry.PluginByID(id)
|
||||
source, plugin, client, okPlugin := h.findPluginStoreInstallTarget(installCtx, proxyURL, sources, id, c.Query("source"), c)
|
||||
if !okPlugin {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "plugin_not_found", "message": "plugin not found in registry"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -236,6 +277,7 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) {
|
||||
h.reloadConfigAfterManagementSave(c.Request.Context(), reloadCfg)
|
||||
log.WithFields(log.Fields{
|
||||
"plugin_id": result.ID,
|
||||
"source_id": source.ID,
|
||||
"version": result.Version,
|
||||
"path": result.Path,
|
||||
"overwritten": result.Overwritten,
|
||||
@@ -243,6 +285,9 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) {
|
||||
|
||||
c.JSON(http.StatusOK, pluginInstallResponse{
|
||||
Status: "installed",
|
||||
SourceID: htmlsanitize.String(source.ID),
|
||||
SourceName: htmlsanitize.String(source.Name),
|
||||
SourceURL: htmlsanitize.String(source.URL),
|
||||
ID: htmlsanitize.String(result.ID),
|
||||
Version: htmlsanitize.String(result.Version),
|
||||
Path: htmlsanitize.String(result.Path),
|
||||
@@ -265,27 +310,39 @@ func (h *Handler) enablePluginConfigLocked(id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) pluginStoreSnapshot() (bool, string, string, map[string]config.PluginInstanceConfig, *pluginhost.Host) {
|
||||
if h == nil || h.cfg == nil {
|
||||
return false, "plugins", "", map[string]config.PluginInstanceConfig{}, nil
|
||||
func (h *Handler) pluginStoreSnapshot() (bool, string, string, []string, map[string]config.PluginInstanceConfig, *pluginhost.Host) {
|
||||
if h == nil {
|
||||
return false, "plugins", "", nil, map[string]config.PluginInstanceConfig{}, nil
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.cfg == nil {
|
||||
return false, "plugins", "", nil, map[string]config.PluginInstanceConfig{}, nil
|
||||
}
|
||||
pluginsEnabled := h.cfg.Plugins.Enabled
|
||||
pluginsDir := normalizedPluginsDir(h.cfg.Plugins.Dir)
|
||||
proxyURL := strings.TrimSpace(h.cfg.ProxyURL)
|
||||
sourceConfigs := append([]string(nil), h.cfg.Plugins.StoreSources...)
|
||||
configs := make(map[string]config.PluginInstanceConfig, len(h.cfg.Plugins.Configs))
|
||||
for id, item := range h.cfg.Plugins.Configs {
|
||||
configs[id] = item
|
||||
}
|
||||
return pluginsEnabled, pluginsDir, proxyURL, configs, h.pluginHost
|
||||
return pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, configs, h.pluginHost
|
||||
}
|
||||
|
||||
func (h *Handler) newPluginStoreClient(proxyURL string) pluginstore.Client {
|
||||
registryURL := ""
|
||||
func (h *Handler) pluginStoreSources(sourceConfigs []string) ([]pluginstore.Source, error) {
|
||||
if h != nil && strings.TrimSpace(h.pluginStoreRegistryURL) != "" {
|
||||
source := pluginstore.DefaultSource()
|
||||
source.URL = strings.TrimSpace(h.pluginStoreRegistryURL)
|
||||
return []pluginstore.Source{source}, nil
|
||||
}
|
||||
return pluginstore.NormalizeSources(sourceConfigs)
|
||||
}
|
||||
|
||||
func (h *Handler) newPluginStoreClient(proxyURL string, registryURL string) pluginstore.Client {
|
||||
registryURL = strings.TrimSpace(registryURL)
|
||||
var httpClient pluginstore.HTTPDoer
|
||||
if h != nil {
|
||||
registryURL = strings.TrimSpace(h.pluginStoreRegistryURL)
|
||||
httpClient = h.pluginStoreHTTPClient
|
||||
}
|
||||
if registryURL == "" {
|
||||
@@ -301,6 +358,115 @@ func (h *Handler) newPluginStoreClient(proxyURL string) pluginstore.Client {
|
||||
return pluginstore.Client{HTTPClient: client, RegistryURL: registryURL}
|
||||
}
|
||||
|
||||
func (h *Handler) fetchSourcedPlugins(ctx context.Context, proxyURL string, sources []pluginstore.Source) ([]sourcedPlugin, []pluginStoreSourceErr) {
|
||||
plugins := make([]sourcedPlugin, 0)
|
||||
sourceErrors := make([]pluginStoreSourceErr, 0)
|
||||
for _, source := range sources {
|
||||
client := h.newPluginStoreClient(proxyURL, source.URL)
|
||||
registry, errRegistry := client.FetchRegistry(ctx)
|
||||
if errRegistry != nil {
|
||||
sourceErrors = append(sourceErrors, pluginStoreSourceErr{
|
||||
SourceID: source.ID,
|
||||
SourceName: source.Name,
|
||||
SourceURL: source.URL,
|
||||
Message: errRegistry.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
for _, plugin := range registry.Plugins {
|
||||
plugins = append(plugins, sourcedPlugin{source: source, plugin: plugin})
|
||||
}
|
||||
}
|
||||
return plugins, sourceErrors
|
||||
}
|
||||
|
||||
func (h *Handler) findPluginStoreInstallTarget(ctx context.Context, proxyURL string, sources []pluginstore.Source, id string, requestedSourceID string, c *gin.Context) (pluginstore.Source, pluginstore.Plugin, pluginstore.Client, bool) {
|
||||
requestedSourceID = strings.TrimSpace(requestedSourceID)
|
||||
if requestedSourceID != "" {
|
||||
for _, source := range sources {
|
||||
if source.ID != requestedSourceID {
|
||||
continue
|
||||
}
|
||||
client := h.newPluginStoreClient(proxyURL, source.URL)
|
||||
registry, errRegistry := client.FetchRegistry(ctx)
|
||||
if errRegistry != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_store_registry_failed", "message": errRegistry.Error()})
|
||||
return pluginstore.Source{}, pluginstore.Plugin{}, pluginstore.Client{}, false
|
||||
}
|
||||
plugin, okPlugin := registry.PluginByID(id)
|
||||
if !okPlugin {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "plugin_not_found", "message": "plugin not found in registry source"})
|
||||
return pluginstore.Source{}, pluginstore.Plugin{}, pluginstore.Client{}, false
|
||||
}
|
||||
return source, plugin, client, true
|
||||
}
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "plugin_store_source_not_found", "message": "plugin store source not found"})
|
||||
return pluginstore.Source{}, pluginstore.Plugin{}, pluginstore.Client{}, false
|
||||
}
|
||||
|
||||
plugins, sourceErrors := h.fetchSourcedPlugins(ctx, proxyURL, sources)
|
||||
matches := make([]sourcedPlugin, 0)
|
||||
for _, item := range plugins {
|
||||
if item.plugin.ID == id {
|
||||
matches = append(matches, item)
|
||||
}
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
if len(plugins) == 0 && len(sourceErrors) > 0 {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_store_registry_failed", "message": sourceErrors[0].Message})
|
||||
return pluginstore.Source{}, pluginstore.Plugin{}, pluginstore.Client{}, false
|
||||
}
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "plugin_not_found", "message": "plugin not found in registry"})
|
||||
return pluginstore.Source{}, pluginstore.Plugin{}, pluginstore.Client{}, false
|
||||
}
|
||||
if len(matches) > 1 {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "plugin_store_source_required",
|
||||
"message": "multiple plugin store sources contain this plugin id; specify source",
|
||||
"sources": sanitizePluginStoreSources(sourcedPluginSources(matches)),
|
||||
})
|
||||
return pluginstore.Source{}, pluginstore.Plugin{}, pluginstore.Client{}, false
|
||||
}
|
||||
match := matches[0]
|
||||
return match.source, match.plugin, h.newPluginStoreClient(proxyURL, match.source.URL), true
|
||||
}
|
||||
|
||||
func sourcedPluginSources(plugins []sourcedPlugin) []pluginstore.Source {
|
||||
sources := make([]pluginstore.Source, 0, len(plugins))
|
||||
for _, item := range plugins {
|
||||
sources = append(sources, item.source)
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
func sanitizePluginStoreSources(sources []pluginstore.Source) []pluginStoreSource {
|
||||
out := make([]pluginStoreSource, 0, len(sources))
|
||||
for _, source := range sources {
|
||||
out = append(out, pluginStoreSource{
|
||||
ID: htmlsanitize.String(source.ID),
|
||||
Name: htmlsanitize.String(source.Name),
|
||||
URL: htmlsanitize.String(source.URL),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sanitizePluginStoreSourceErrors(sourceErrors []pluginStoreSourceErr) []pluginStoreSourceErr {
|
||||
if len(sourceErrors) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]pluginStoreSourceErr, 0, len(sourceErrors))
|
||||
for _, sourceError := range sourceErrors {
|
||||
out = append(out, pluginStoreSourceErr{
|
||||
SourceID: htmlsanitize.String(sourceError.SourceID),
|
||||
SourceName: htmlsanitize.String(sourceError.SourceName),
|
||||
SourceURL: htmlsanitize.String(sourceError.SourceURL),
|
||||
Message: htmlsanitize.String(sourceError.Message),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// latestPluginVersions resolves the latest release version of each registry
|
||||
// plugin concurrently, returning results positionally aligned with plugins.
|
||||
// Unresolved entries are left empty so callers can fall back gracefully.
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginstore"
|
||||
)
|
||||
|
||||
func TestListPluginStoreMergesInstalledStatus(t *testing.T) {
|
||||
@@ -239,6 +240,68 @@ func TestListPluginStoreFallsBackToRegistryVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPluginStoreIncludesThirdPartySources(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
Plugins: config.PluginsConfig{
|
||||
Enabled: true,
|
||||
Dir: t.TempDir(),
|
||||
StoreSources: []string{"https://community.example/registry.json"},
|
||||
},
|
||||
},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
pluginStoreHTTPClient: fakePluginStoreHTTPClient{
|
||||
pluginstore.DefaultRegistryURL: registryJSON(t),
|
||||
"https://community.example/registry.json": []byte(`{
|
||||
"schema_version": 1,
|
||||
"plugins": [{
|
||||
"id": "third-provider",
|
||||
"name": "Third Provider",
|
||||
"description": "Adds third-party provider support.",
|
||||
"author": "community",
|
||||
"version": "0.3.0",
|
||||
"repository": "https://github.com/community/cliproxy-third-provider-plugin"
|
||||
}]
|
||||
}`),
|
||||
},
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugin-store", nil)
|
||||
|
||||
h.ListPluginStore(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
var body pluginStoreListResponse
|
||||
if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil {
|
||||
t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String())
|
||||
}
|
||||
if len(body.Sources) != 2 {
|
||||
t.Fatalf("sources len = %d, want 2: %#v", len(body.Sources), body.Sources)
|
||||
}
|
||||
if len(body.Plugins) != 2 {
|
||||
t.Fatalf("plugins len = %d, want 2: %#v", len(body.Plugins), body.Plugins)
|
||||
}
|
||||
byID := map[string]pluginStoreListEntry{}
|
||||
for _, entry := range body.Plugins {
|
||||
byID[entry.ID] = entry
|
||||
}
|
||||
if byID["sample-provider"].SourceID != pluginstore.DefaultSourceID {
|
||||
t.Fatalf("official source id = %q, want %q", byID["sample-provider"].SourceID, pluginstore.DefaultSourceID)
|
||||
}
|
||||
third := byID["third-provider"]
|
||||
communitySourceID := pluginstore.SourceID("https://community.example/registry.json")
|
||||
if third.StoreID != communitySourceID+"/third-provider" || third.SourceID != communitySourceID || third.SourceName != "community.example" || third.SourceURL != "https://community.example/registry.json" {
|
||||
t.Fatalf("third-party source fields = %#v", third)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallPluginFromStoreWritesFileAndEnablesConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
@@ -317,6 +380,100 @@ func TestInstallPluginFromStoreWritesFileAndEnablesConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallPluginFromStoreUsesRequestedThirdPartySource(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
pluginsDir := t.TempDir()
|
||||
archiveData := makeManagementPluginStoreZip(t, "sample-provider"+managementPluginExtension(runtime.GOOS), "third-party-library-data")
|
||||
archiveName := "sample-provider_0.3.0_" + runtime.GOOS + "_" + runtime.GOARCH + ".zip"
|
||||
checksum := sha256.Sum256(archiveData)
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
Plugins: config.PluginsConfig{
|
||||
Enabled: false,
|
||||
Dir: pluginsDir,
|
||||
StoreSources: []string{"https://community.example/registry.json"},
|
||||
},
|
||||
},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
pluginStoreHTTPClient: fakePluginStoreHTTPClient{
|
||||
pluginstore.DefaultRegistryURL: registryJSON(t),
|
||||
"https://community.example/registry.json": thirdPartySampleRegistryJSON(t),
|
||||
"https://api.github.com/repos/community/cliproxy-sample-provider-plugin/releases/latest": []byte(`{
|
||||
"tag_name": "v0.3.0",
|
||||
"assets": [
|
||||
{"name": "` + archiveName + `", "browser_download_url": "https://downloads.example/` + archiveName + `"},
|
||||
{"name": "checksums.txt", "browser_download_url": "https://downloads.example/checksums.txt"}
|
||||
]
|
||||
}`),
|
||||
"https://downloads.example/" + archiveName: archiveData,
|
||||
"https://downloads.example/checksums.txt": []byte(hex.EncodeToString(checksum[:]) + " " + archiveName + "\n"),
|
||||
},
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Params = gin.Params{{Key: "id", Value: "sample-provider"}}
|
||||
communitySourceID := pluginstore.SourceID("https://community.example/registry.json")
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v0/management/plugin-store/sample-provider/install?source="+communitySourceID, nil)
|
||||
|
||||
h.InstallPluginFromStore(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
var body pluginInstallResponse
|
||||
if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil {
|
||||
t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String())
|
||||
}
|
||||
if body.SourceID != communitySourceID || body.Version != "0.3.0" {
|
||||
t.Fatalf("install response = %#v, want community source version 0.3.0", body)
|
||||
}
|
||||
targetPath := filepath.Join(pluginsDir, runtime.GOOS, runtime.GOARCH, "sample-provider"+managementPluginExtension(runtime.GOOS))
|
||||
data, errRead := os.ReadFile(targetPath)
|
||||
if errRead != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", targetPath, errRead)
|
||||
}
|
||||
if string(data) != "third-party-library-data" {
|
||||
t.Fatalf("installed file = %q, want third-party-library-data", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallPluginFromStoreRequiresSourceForDuplicateIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
Plugins: config.PluginsConfig{
|
||||
Enabled: false,
|
||||
Dir: t.TempDir(),
|
||||
StoreSources: []string{"https://community.example/registry.json"},
|
||||
},
|
||||
},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
pluginStoreHTTPClient: fakePluginStoreHTTPClient{
|
||||
pluginstore.DefaultRegistryURL: registryJSON(t),
|
||||
"https://community.example/registry.json": thirdPartySampleRegistryJSON(t),
|
||||
},
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Params = gin.Params{{Key: "id", Value: "sample-provider"}}
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v0/management/plugin-store/sample-provider/install", nil)
|
||||
|
||||
h.InstallPluginFromStore(c)
|
||||
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusConflict, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "plugin_store_source_required") {
|
||||
t.Fatalf("body = %s, want source required error", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallPluginFromStoreOverwritesFilePreservesConfigAndReloads(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
@@ -500,6 +657,22 @@ func registryJSON(t *testing.T) []byte {
|
||||
}`)
|
||||
}
|
||||
|
||||
func thirdPartySampleRegistryJSON(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
|
||||
return []byte(`{
|
||||
"schema_version": 1,
|
||||
"plugins": [{
|
||||
"id": "sample-provider",
|
||||
"name": "Sample Provider Community Build",
|
||||
"description": "Adds sample provider support from a third-party source.",
|
||||
"author": "community",
|
||||
"version": "0.3.0",
|
||||
"repository": "https://github.com/community/cliproxy-sample-provider-plugin"
|
||||
}]
|
||||
}`)
|
||||
}
|
||||
|
||||
func makeManagementPluginStoreZip(t *testing.T, name string, content string) []byte {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -156,6 +156,8 @@ type PluginsConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
// Dir is the plugin discovery directory.
|
||||
Dir string `yaml:"dir" json:"dir"`
|
||||
// StoreSources appends third-party plugin store registries to the built-in official source.
|
||||
StoreSources []string `yaml:"store-sources,omitempty" json:"store-sources,omitempty"`
|
||||
// Configs stores per-plugin instance configuration by plugin ID.
|
||||
Configs map[string]PluginInstanceConfig `yaml:"configs" json:"configs"`
|
||||
}
|
||||
@@ -776,6 +778,17 @@ func (cfg *Config) NormalizePluginsConfig() {
|
||||
if cfg.Plugins.Dir == "" {
|
||||
cfg.Plugins.Dir = "plugins"
|
||||
}
|
||||
if len(cfg.Plugins.StoreSources) > 0 {
|
||||
sources := make([]string, 0, len(cfg.Plugins.StoreSources))
|
||||
for _, source := range cfg.Plugins.StoreSources {
|
||||
source = strings.TrimSpace(source)
|
||||
if source == "" {
|
||||
continue
|
||||
}
|
||||
sources = append(sources, source)
|
||||
}
|
||||
cfg.Plugins.StoreSources = sources
|
||||
}
|
||||
if cfg.Plugins.Configs == nil {
|
||||
cfg.Plugins.Configs = map[string]PluginInstanceConfig{}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,26 @@ plugins: {}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigBytes_PluginStoreSources(t *testing.T) {
|
||||
cfg, errParse := ParseConfigBytes([]byte(`
|
||||
plugins:
|
||||
store-sources:
|
||||
- " https://community.example/registry.json "
|
||||
- ""
|
||||
`))
|
||||
if errParse != nil {
|
||||
t.Fatalf("ParseConfigBytes() error = %v", errParse)
|
||||
}
|
||||
|
||||
if len(cfg.Plugins.StoreSources) != 1 {
|
||||
t.Fatalf("Plugins.StoreSources len = %d, want 1", len(cfg.Plugins.StoreSources))
|
||||
}
|
||||
source := cfg.Plugins.StoreSources[0]
|
||||
if source != "https://community.example/registry.json" {
|
||||
t.Fatalf("Plugins.StoreSources[0] = %#v", source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigBytes_PluginInstanceEmptyRawYAML(t *testing.T) {
|
||||
cfg, errParse := ParseConfigBytes([]byte(`
|
||||
plugins:
|
||||
|
||||
@@ -2,6 +2,8 @@ package pluginstore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
@@ -13,11 +15,19 @@ import (
|
||||
|
||||
const (
|
||||
DefaultRegistryURL = "https://raw.githubusercontent.com/router-for-me/CLIProxyAPI-Plugins-Store/main/registry.json"
|
||||
DefaultSourceID = "official"
|
||||
DefaultSourceName = "Official"
|
||||
SchemaVersion = 1
|
||||
)
|
||||
|
||||
var pluginVersionPattern = regexp.MustCompile(`^[0-9][0-9A-Za-z.+-]*$`)
|
||||
|
||||
type Source struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Plugins []Plugin `json:"plugins"`
|
||||
@@ -36,6 +46,54 @@ type Plugin struct {
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
func DefaultSource() Source {
|
||||
return Source{
|
||||
ID: DefaultSourceID,
|
||||
Name: DefaultSourceName,
|
||||
URL: DefaultRegistryURL,
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeSources(registryURLs []string) ([]Source, error) {
|
||||
out := []Source{DefaultSource()}
|
||||
seenIDs := map[string]string{DefaultSourceID: DefaultRegistryURL}
|
||||
seenURLs := map[string]struct{}{DefaultRegistryURL: {}}
|
||||
for _, registryURL := range registryURLs {
|
||||
registryURL = strings.TrimSpace(registryURL)
|
||||
if registryURL == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seenURLs[registryURL]; exists {
|
||||
continue
|
||||
}
|
||||
source := Source{
|
||||
ID: SourceID(registryURL),
|
||||
Name: SourceName(registryURL),
|
||||
URL: registryURL,
|
||||
}
|
||||
if existingURL, exists := seenIDs[source.ID]; exists {
|
||||
return nil, fmt.Errorf("plugin store source id collision for %q and %q", existingURL, registryURL)
|
||||
}
|
||||
seenIDs[source.ID] = registryURL
|
||||
seenURLs[registryURL] = struct{}{}
|
||||
out = append(out, source)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func SourceID(registryURL string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(registryURL)))
|
||||
return "source-" + hex.EncodeToString(sum[:])[:12]
|
||||
}
|
||||
|
||||
func SourceName(registryURL string) string {
|
||||
parsed, errParse := url.Parse(strings.TrimSpace(registryURL))
|
||||
if errParse != nil || strings.TrimSpace(parsed.Host) == "" {
|
||||
return strings.TrimSpace(registryURL)
|
||||
}
|
||||
return parsed.Host
|
||||
}
|
||||
|
||||
func ParseRegistry(data []byte) (Registry, error) {
|
||||
var registry Registry
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
|
||||
@@ -160,6 +160,42 @@ func TestValidateRegistryRejectsInvalidEntries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSourcesAppendsURLsToDefaultSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sources, errNormalize := NormalizeSources([]string{" https://community.example/registry.json "})
|
||||
if errNormalize != nil {
|
||||
t.Fatalf("NormalizeSources() error = %v", errNormalize)
|
||||
}
|
||||
if len(sources) != 2 {
|
||||
t.Fatalf("sources len = %d, want 2", len(sources))
|
||||
}
|
||||
if sources[0].ID != DefaultSourceID || sources[0].URL != DefaultRegistryURL {
|
||||
t.Fatalf("default source = %#v", sources[0])
|
||||
}
|
||||
if sources[1].ID != SourceID("https://community.example/registry.json") ||
|
||||
sources[1].Name != "community.example" ||
|
||||
sources[1].URL != "https://community.example/registry.json" {
|
||||
t.Fatalf("third-party source = %#v", sources[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSourcesSkipsDuplicates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sources, errNormalize := NormalizeSources([]string{
|
||||
DefaultRegistryURL,
|
||||
"https://community.example/registry.json",
|
||||
"https://community.example/registry.json",
|
||||
})
|
||||
if errNormalize != nil {
|
||||
t.Fatalf("NormalizeSources() error = %v", errNormalize)
|
||||
}
|
||||
if len(sources) != 2 {
|
||||
t.Fatalf("sources len = %d, want 2: %#v", len(sources), sources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubRepositoryPartsRejectsNonRepositoryURLs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user