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.
This commit is contained in:
LTbinglingfeng
2026-06-13 04:00:05 +08:00
parent 44d3066a9c
commit 40f4b8b856
5 changed files with 160 additions and 8 deletions
@@ -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 + `"},
+35 -5
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 {
+36
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()
+6 -1
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
+81
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",