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