From 40f4b8b8567dc3327d2b86912a7d682f9cd49a3b Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Sat, 13 Jun 2026 04:00:05 +0800 Subject: [PATCH] feat(pluginstore): fetch and install plugins from latest release Replace the tag-pinned release lookup with the repository latest release endpoint. Derive the plugin version from the release tag, validate it, and attach an optional token to API requests to raise the rate limit. --- .../handlers/management/plugin_store_test.go | 4 +- internal/pluginstore/github.go | 40 +++++++-- internal/pluginstore/github_test.go | 36 +++++++++ internal/pluginstore/install.go | 7 +- internal/pluginstore/install_test.go | 81 +++++++++++++++++++ 5 files changed, 160 insertions(+), 8 deletions(-) diff --git a/internal/api/handlers/management/plugin_store_test.go b/internal/api/handlers/management/plugin_store_test.go index dfa8c4f1..5a4804a3 100644 --- a/internal/api/handlers/management/plugin_store_test.go +++ b/internal/api/handlers/management/plugin_store_test.go @@ -168,7 +168,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 +250,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 + `"}, diff --git a/internal/pluginstore/github.go b/internal/pluginstore/github.go index 1132b1ca..19fc0e59 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 39b2c2f9..b96eea58 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 900515c6..314dee05 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 4beed53e..573e77bf 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",