Merge pull request #3821 from router-for-me/feat/plugin-latest-release-check

Feat/plugin latest release check
This commit is contained in:
Supra4E8C
2026-06-13 04:52:24 +08:00
committed by GitHub
9 changed files with 377 additions and 13 deletions

View File

@@ -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.

View File

@@ -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)

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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()