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 dfa8c4f19..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) @@ -168,7 +261,7 @@ func TestInstallPluginFromStoreWritesFileAndEnablesConfig(t *testing.T) { pluginStoreRegistryURL: "https://registry.example/registry.json", pluginStoreHTTPClient: fakePluginStoreHTTPClient{ "https://registry.example/registry.json": registryJSON(t), - "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/tags/v0.1.0": []byte(`{ + "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/latest": []byte(`{ "tag_name": "v0.1.0", "assets": [ {"name": "` + archiveName + `", "browser_download_url": "https://downloads.example/` + archiveName + `"}, @@ -250,7 +343,7 @@ func TestInstallPluginFromStoreOverwritesFilePreservesConfigAndReloads(t *testin pluginStoreRegistryURL: "https://registry.example/registry.json", pluginStoreHTTPClient: fakePluginStoreHTTPClient{ "https://registry.example/registry.json": registryJSON(t), - "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/tags/v0.1.0": []byte(`{ + "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/latest": []byte(`{ "tag_name": "v0.1.0", "assets": [ {"name": "` + archiveName + `", "browser_download_url": "https://downloads.example/` + archiveName + `"}, @@ -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() diff --git a/internal/pluginstore/github.go b/internal/pluginstore/github.go index 1132b1cab..19fc0e591 100644 --- a/internal/pluginstore/github.go +++ b/internal/pluginstore/github.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/url" + "os" "strings" "github.com/router-for-me/CLIProxyAPI/v7/internal/httpfetch" @@ -48,16 +49,17 @@ func (c Client) FetchRegistry(ctx context.Context) (Registry, error) { return registry, nil } -func (c Client) FetchRelease(ctx context.Context, plugin Plugin) (Release, error) { +// FetchLatestRelease returns the latest published release of the plugin's +// GitHub repository, mirroring the WebUI panel update check. +func (c Client) FetchLatestRelease(ctx context.Context, plugin Plugin) (Release, error) { owner, repo, errRepository := GitHubRepositoryParts(plugin.Repository) if errRepository != nil { return Release{}, errRepository } releaseURL := fmt.Sprintf( - "https://api.github.com/repos/%s/%s/releases/tags/%s", + "https://api.github.com/repos/%s/%s/releases/latest", url.PathEscape(owner), url.PathEscape(repo), - url.PathEscape("v"+strings.TrimSpace(plugin.Version)), ) data, errDownload := c.get(ctx, releaseURL, "application/vnd.github+json") if errDownload != nil { @@ -70,6 +72,16 @@ func (c Client) FetchRelease(ctx context.Context, plugin Plugin) (Release, error return release, nil } +// ReleaseVersion derives the plugin version from the release tag, stripping a +// leading "v"/"V" and validating the result. +func ReleaseVersion(release Release) (string, error) { + version := normalizeVersion(release.TagName) + if !validPluginVersion(version) { + return "", fmt.Errorf("invalid release tag %q", release.TagName) + } + return version, nil +} + func (c Client) DownloadAsset(ctx context.Context, asset ReleaseAsset) ([]byte, error) { if strings.TrimSpace(asset.BrowserDownloadURL) == "" { return nil, fmt.Errorf("asset %q missing browser_download_url", asset.Name) @@ -78,10 +90,28 @@ func (c Client) DownloadAsset(ctx context.Context, asset ReleaseAsset) ([]byte, } func (c Client) get(ctx context.Context, requestURL string, accept string) ([]byte, error) { - return httpfetch.GetBytes(ctx, c.httpClient(), requestURL, map[string]string{ + headers := map[string]string{ "Accept": accept, "User-Agent": c.userAgent(), - }, 0) + } + if token := gitHubAPIToken(requestURL); token != "" { + headers["Authorization"] = "Bearer " + token + } + return httpfetch.GetBytes(ctx, c.httpClient(), requestURL, headers, 0) +} + +// gitHubAPIToken returns the optional GitHub token for GitHub API requests to +// raise the unauthenticated rate limit, mirroring the management asset updater. +func gitHubAPIToken(requestURL string) string { + parsed, errParse := url.Parse(requestURL) + if errParse != nil || !strings.EqualFold(parsed.Host, "api.github.com") { + return "" + } + gitURL := strings.ToLower(strings.TrimSpace(os.Getenv("GITSTORE_GIT_URL"))) + if !strings.Contains(gitURL, "github.com") { + return "" + } + return strings.TrimSpace(os.Getenv("GITSTORE_GIT_TOKEN")) } func (c Client) httpClient() HTTPDoer { diff --git a/internal/pluginstore/github_test.go b/internal/pluginstore/github_test.go index 39b2c2f9a..b96eea584 100644 --- a/internal/pluginstore/github_test.go +++ b/internal/pluginstore/github_test.go @@ -64,6 +64,42 @@ func TestSelectReleaseAssetsRejectsMissingAssets(t *testing.T) { } } +func TestReleaseVersion(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tagName string + want string + wantErr bool + }{ + {name: "v prefix", tagName: "v1.2.3", want: "1.2.3"}, + {name: "no prefix", tagName: "0.1.0", want: "0.1.0"}, + {name: "whitespace", tagName: " v2.0.0 ", want: "2.0.0"}, + {name: "empty", tagName: "", wantErr: true}, + {name: "non numeric", tagName: "latest", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + version, errVersion := ReleaseVersion(Release{TagName: tt.tagName}) + if tt.wantErr { + if errVersion == nil { + t.Fatalf("ReleaseVersion(%q) error = nil", tt.tagName) + } + return + } + if errVersion != nil { + t.Fatalf("ReleaseVersion(%q) error = %v", tt.tagName, errVersion) + } + if version != tt.want { + t.Fatalf("ReleaseVersion(%q) = %q, want %q", tt.tagName, version, tt.want) + } + }) + } +} + func TestParseChecksumsAndVerifyChecksum(t *testing.T) { t.Parallel() diff --git a/internal/pluginstore/install.go b/internal/pluginstore/install.go index 900515c6d..314dee05e 100644 --- a/internal/pluginstore/install.go +++ b/internal/pluginstore/install.go @@ -49,10 +49,15 @@ func (c Client) Install(ctx context.Context, plugin Plugin, options InstallOptio if loadedPluginInstallBlocked(options) && options.BeforeWrite == nil { return InstallResult{}, ErrLoadedPluginLocked } - release, errRelease := c.FetchRelease(ctx, plugin) + release, errRelease := c.FetchLatestRelease(ctx, plugin) if errRelease != nil { return InstallResult{}, errRelease } + latestVersion, errVersion := ReleaseVersion(release) + if errVersion != nil { + return InstallResult{}, errVersion + } + plugin.Version = latestVersion archiveAsset, checksumAsset, errAssets := SelectReleaseAssets(release, plugin.ID, plugin.Version, options.GOOS, options.GOARCH) if errAssets != nil { return InstallResult{}, errAssets diff --git a/internal/pluginstore/install_test.go b/internal/pluginstore/install_test.go index 4beed53e3..573e77bfd 100644 --- a/internal/pluginstore/install_test.go +++ b/internal/pluginstore/install_test.go @@ -4,7 +4,10 @@ import ( "archive/zip" "bytes" "context" + "crypto/sha256" + "encoding/hex" "errors" + "io" "net/http" "os" "path/filepath" @@ -249,6 +252,64 @@ func TestInstallArchiveRejectsUnsafeArchives(t *testing.T) { } } +func TestInstallUsesLatestReleaseVersion(t *testing.T) { + t.Parallel() + + root := t.TempDir() + archiveData := makeZip(t, map[string]string{"sample-provider.dylib": "library-data"}) + archiveName := "sample-provider_0.2.0_darwin_arm64.zip" + checksum := sha256.Sum256(archiveData) + client := Client{HTTPClient: mapHTTPDoer{ + "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/latest": []byte(`{ + "tag_name": "v0.2.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"), + }} + + result, errInstall := client.Install(context.Background(), testPlugin(), InstallOptions{ + PluginsDir: root, + GOOS: "darwin", + GOARCH: "arm64", + }) + if errInstall != nil { + t.Fatalf("Install() error = %v", errInstall) + } + if result.Version != "0.2.0" { + t.Fatalf("Version = %q, want 0.2.0 from latest release tag", result.Version) + } + data, errRead := os.ReadFile(filepath.Join(root, "darwin", "arm64", "sample-provider.dylib")) + if errRead != nil { + t.Fatalf("ReadFile() error = %v", errRead) + } + if string(data) != "library-data" { + t.Fatalf("installed data = %q", data) + } +} + +func TestInstallRejectsInvalidLatestReleaseTag(t *testing.T) { + t.Parallel() + + client := Client{HTTPClient: mapHTTPDoer{ + "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/latest": []byte(`{"tag_name": "latest", "assets": []}`), + }} + _, errInstall := client.Install(context.Background(), testPlugin(), InstallOptions{ + PluginsDir: t.TempDir(), + GOOS: "darwin", + GOARCH: "arm64", + }) + if errInstall == nil { + t.Fatal("Install() error = nil") + } + if !strings.Contains(errInstall.Error(), "invalid release tag") { + t.Fatalf("Install() error = %v, want invalid release tag", errInstall) + } +} + func makeZip(t *testing.T, files map[string]string) []byte { t.Helper() @@ -275,6 +336,26 @@ func (failingHTTPDoer) Do(*http.Request) (*http.Response, error) { return nil, errors.New("network unavailable") } +type mapHTTPDoer map[string][]byte + +func (c mapHTTPDoer) Do(req *http.Request) (*http.Response, error) { + body, ok := c[req.URL.String()] + if !ok { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader("not found")), + Header: make(http.Header), + Request: req, + }, nil + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(body)), + Header: make(http.Header), + Request: req, + }, nil +} + func testPlugin() Plugin { return Plugin{ ID: "sample-provider", diff --git a/internal/pluginstore/registry.go b/internal/pluginstore/registry.go index 6a20fabce..f49a91f83 100644 --- a/internal/pluginstore/registry.go +++ b/internal/pluginstore/registry.go @@ -94,7 +94,6 @@ func ValidatePlugin(plugin Plugin) error { "name": plugin.Name, "description": plugin.Description, "author": plugin.Author, - "version": plugin.Version, "repository": plugin.Repository, } for field, value := range required { @@ -105,7 +104,9 @@ func ValidatePlugin(plugin Plugin) error { if !pluginhost.ValidatePluginID(strings.TrimSpace(plugin.ID)) { return fmt.Errorf("invalid plugin id %q", plugin.ID) } - if !validPluginVersion(strings.TrimSpace(plugin.Version)) { + // The version is optional since the latest release is the source of truth; + // when present it is only used as a display fallback and must be valid. + if version := strings.TrimSpace(plugin.Version); version != "" && !validPluginVersion(version) { return fmt.Errorf("invalid plugin version %q", plugin.Version) } if _, _, errRepository := GitHubRepositoryParts(plugin.Repository); errRepository != nil { diff --git a/internal/pluginstore/registry_test.go b/internal/pluginstore/registry_test.go index d8c89e8d6..1f95f4fbb 100644 --- a/internal/pluginstore/registry_test.go +++ b/internal/pluginstore/registry_test.go @@ -68,6 +68,21 @@ func TestParseRegistryNormalizesPluginFields(t *testing.T) { } } +func TestValidateRegistryAllowsMissingVersion(t *testing.T) { + t.Parallel() + + registry := Registry{SchemaVersion: 1, Plugins: []Plugin{{ + ID: "sample-provider", + Name: "Sample Provider", + Description: "Adds sample provider support.", + Author: "author-name", + Repository: "https://github.com/author-name/cliproxy-sample-provider-plugin", + }}} + if errValidate := ValidateRegistry(registry); errValidate != nil { + t.Fatalf("ValidateRegistry() error = %v, want nil for missing version", errValidate) + } +} + func TestValidateRegistryRejectsInvalidEntries(t *testing.T) { t.Parallel()