diff --git a/internal/api/handlers/management/handler.go b/internal/api/handlers/management/handler.go index 98d333d33..ba2ef3c9b 100644 --- a/internal/api/handlers/management/handler.go +++ b/internal/api/handlers/management/handler.go @@ -54,6 +54,8 @@ type Handler struct { configReloadHook func(context.Context, *config.Config) pluginStoreRegistryURL string pluginStoreHTTPClient pluginstore.HTTPDoer + pluginReleaseCacheMu sync.Mutex + pluginReleaseCache map[string]pluginReleaseCacheEntry } // NewHandler creates a new management handler instance. diff --git a/internal/api/handlers/management/plugin_store.go b/internal/api/handlers/management/plugin_store.go index 7d8179a78..969e6ce64 100644 --- a/internal/api/handlers/management/plugin_store.go +++ b/internal/api/handlers/management/plugin_store.go @@ -1,11 +1,14 @@ package management import ( + "context" "errors" "fmt" "net/http" "runtime" "strings" + "sync" + "time" "github.com/gin-gonic/gin" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" @@ -17,6 +20,20 @@ import ( log "github.com/sirupsen/logrus" ) +const ( + // pluginReleaseCacheTTL bounds how long a resolved latest release version is + // reused before the GitHub API is queried again. + pluginReleaseCacheTTL = 10 * time.Minute + // pluginReleaseFailureCacheTTL throttles retries after a failed lookup so a + // rate-limited or unreachable API is not hammered on every listing. + pluginReleaseFailureCacheTTL = 30 * time.Second +) + +type pluginReleaseCacheEntry struct { + version string + expiresAt time.Time +} + type pluginStoreListResponse struct { PluginsEnabled bool `json:"plugins_enabled"` PluginsDir string `json:"plugins_dir"` @@ -77,16 +94,23 @@ func (h *Handler) ListPluginStore(c *gin.Context) { return } + latestVersions := h.latestPluginVersions(c.Request.Context(), client, registry.Plugins) + entries := make([]pluginStoreListEntry, 0, len(registry.Plugins)) - for _, plugin := range registry.Plugins { + for index, plugin := range registry.Plugins { status := statuses[plugin.ID] installedVersion := status.InstalledVersion + // Fall back to the registry version when the latest release is unknown. + storeVersion := plugin.Version + if latestVersions[index] != "" { + storeVersion = latestVersions[index] + } entries = append(entries, pluginStoreListEntry{ ID: htmlsanitize.String(plugin.ID), Name: htmlsanitize.String(plugin.Name), Description: htmlsanitize.String(plugin.Description), Author: htmlsanitize.String(plugin.Author), - Version: htmlsanitize.String(plugin.Version), + Version: htmlsanitize.String(storeVersion), Repository: htmlsanitize.String(plugin.Repository), Logo: htmlsanitize.String(plugin.Logo), Homepage: htmlsanitize.String(plugin.Homepage), @@ -99,7 +123,7 @@ func (h *Handler) ListPluginStore(c *gin.Context) { Registered: status.Registered, Enabled: status.Enabled, EffectiveEnabled: status.EffectiveEnabled, - UpdateAvailable: pluginstore.UpdateAvailable(installedVersion, plugin.Version), + UpdateAvailable: pluginstore.UpdateAvailable(installedVersion, storeVersion), }) } @@ -276,6 +300,61 @@ func (h *Handler) newPluginStoreClient(proxyURL string) pluginstore.Client { return pluginstore.Client{HTTPClient: client, RegistryURL: registryURL} } +// 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. +func (h *Handler) latestPluginVersions(ctx context.Context, client pluginstore.Client, plugins []pluginstore.Plugin) []string { + versions := make([]string, len(plugins)) + var wg sync.WaitGroup + for index := range plugins { + wg.Add(1) + go func(index int) { + defer wg.Done() + versions[index] = h.latestPluginVersion(ctx, client, plugins[index]) + }(index) + } + wg.Wait() + return versions +} + +// latestPluginVersion returns the plugin's latest release version, caching +// lookups per repository so repeated listings do not exhaust the GitHub API +// rate limit. Failed lookups are cached for a shorter interval and reported +// as an empty version. +func (h *Handler) latestPluginVersion(ctx context.Context, client pluginstore.Client, plugin pluginstore.Plugin) string { + repository := strings.TrimSpace(plugin.Repository) + if repository == "" { + return "" + } + now := time.Now() + h.pluginReleaseCacheMu.Lock() + entry, found := h.pluginReleaseCache[repository] + h.pluginReleaseCacheMu.Unlock() + if found && now.Before(entry.expiresAt) { + return entry.version + } + + version := "" + ttl := pluginReleaseFailureCacheTTL + release, errRelease := client.FetchLatestRelease(ctx, plugin) + if errRelease != nil { + log.WithError(errRelease).WithField("plugin_id", plugin.ID).Warn("pluginstore: failed to fetch latest release") + } else if latestVersion, errVersion := pluginstore.ReleaseVersion(release); errVersion != nil { + log.WithError(errVersion).WithField("plugin_id", plugin.ID).Warn("pluginstore: invalid latest release tag") + } else { + version = latestVersion + ttl = pluginReleaseCacheTTL + } + + h.pluginReleaseCacheMu.Lock() + if h.pluginReleaseCache == nil { + h.pluginReleaseCache = make(map[string]pluginReleaseCacheEntry) + } + h.pluginReleaseCache[repository] = pluginReleaseCacheEntry{version: version, expiresAt: now.Add(ttl)} + h.pluginReleaseCacheMu.Unlock() + return version +} + func pluginLocalStatuses(pluginsEnabled bool, pluginsDir string, configs map[string]config.PluginInstanceConfig, host *pluginhost.Host) (map[string]pluginLocalStatus, error) { statuses := map[string]pluginLocalStatus{} files, errDiscover := pluginhost.DiscoverPluginFiles(pluginsDir) diff --git a/internal/api/handlers/management/plugin_store_test.go b/internal/api/handlers/management/plugin_store_test.go index 5a4804a36..4cb59b4e4 100644 --- a/internal/api/handlers/management/plugin_store_test.go +++ b/internal/api/handlers/management/plugin_store_test.go @@ -15,6 +15,7 @@ import ( "path/filepath" "runtime" "strings" + "sync" "testing" "github.com/gin-gonic/gin" @@ -146,6 +147,98 @@ func TestListPluginStoreEscapesRegistryStrings(t *testing.T) { } } +func TestListPluginStoreShowsLatestReleaseVersionAndCaches(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + httpClient := &countingPluginStoreHTTPClient{responses: fakePluginStoreHTTPClient{ + "https://registry.example/registry.json": registryJSON(t), + "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/latest": []byte(`{ + "tag_name": "v0.2.0", + "assets": [] + }`), + }} + h := &Handler{ + cfg: &config.Config{ + Plugins: config.PluginsConfig{ + Enabled: true, + Dir: t.TempDir(), + }, + }, + configFilePath: writeTestConfigFile(t), + pluginStoreRegistryURL: "https://registry.example/registry.json", + pluginStoreHTTPClient: httpClient, + } + + listOnce := func() pluginStoreListResponse { + 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()) + } + return body + } + + for call := 0; call < 2; call++ { + body := listOnce() + if len(body.Plugins) != 1 { + t.Fatalf("plugins len = %d, want 1", len(body.Plugins)) + } + if body.Plugins[0].Version != "0.2.0" { + t.Fatalf("version = %q, want 0.2.0 from latest release tag", body.Plugins[0].Version) + } + } + releaseCalls := httpClient.count("https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/latest") + if releaseCalls != 1 { + t.Fatalf("latest release fetched %d times, want 1 (cached)", releaseCalls) + } +} + +func TestListPluginStoreFallsBackToRegistryVersion(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + h := &Handler{ + cfg: &config.Config{ + Plugins: config.PluginsConfig{ + Enabled: true, + Dir: t.TempDir(), + }, + }, + configFilePath: writeTestConfigFile(t), + pluginStoreRegistryURL: "https://registry.example/registry.json", + pluginStoreHTTPClient: fakePluginStoreHTTPClient{ + "https://registry.example/registry.json": registryJSON(t), + }, + } + + 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.Plugins) != 1 { + t.Fatalf("plugins len = %d, want 1", len(body.Plugins)) + } + if body.Plugins[0].Version != "0.1.0" { + t.Fatalf("version = %q, want registry fallback 0.1.0", body.Plugins[0].Version) + } +} + func TestInstallPluginFromStoreWritesFileAndEnablesConfig(t *testing.T) { t.Parallel() gin.SetMode(gin.TestMode) @@ -368,6 +461,28 @@ func (c fakePluginStoreHTTPClient) Do(req *http.Request) (*http.Response, error) }, nil } +type countingPluginStoreHTTPClient struct { + responses fakePluginStoreHTTPClient + mu sync.Mutex + counts map[string]int +} + +func (c *countingPluginStoreHTTPClient) Do(req *http.Request) (*http.Response, error) { + c.mu.Lock() + if c.counts == nil { + c.counts = make(map[string]int) + } + c.counts[req.URL.String()]++ + c.mu.Unlock() + return c.responses.Do(req) +} + +func (c *countingPluginStoreHTTPClient) count(url string) int { + c.mu.Lock() + defer c.mu.Unlock() + return c.counts[url] +} + func registryJSON(t *testing.T) []byte { t.Helper()