mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-08 01:16:47 +08:00
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:
@@ -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 + `"},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user