diff --git a/internal/pluginstore/auth.go b/internal/pluginstore/auth.go index c7224028..72d16a72 100644 --- a/internal/pluginstore/auth.go +++ b/internal/pluginstore/auth.go @@ -82,11 +82,7 @@ func AuthConfigured(auth []AuthConfig, requestURL string, kind string) bool { case AuthTypeNone: return false case AuthTypeBearer, AuthTypeGitHubToken: - envName := item.TokenEnv - if envName == "" && item.Type == AuthTypeGitHubToken { - envName = "GITSTORE_GIT_TOKEN" - } - return strings.TrimSpace(os.Getenv(envName)) != "" + return strings.TrimSpace(os.Getenv(item.TokenEnv)) != "" case AuthTypeBasic: return strings.TrimSpace(os.Getenv(item.UsernameEnv)) != "" && strings.TrimSpace(os.Getenv(item.PasswordEnv)) != "" case AuthTypeHeader: @@ -162,12 +158,9 @@ func applyPluginStoreAuth(headers http.Header, auth []AuthConfig, requestURL str } headers.Set(item.HeaderName, value) case AuthTypeGitHubToken: - token := strings.TrimSpace(os.Getenv(strings.TrimSpace(item.TokenEnv))) - if token == "" { - token = strings.TrimSpace(os.Getenv("GITSTORE_GIT_TOKEN")) - } - if token == "" { - return fmt.Errorf("plugin store auth missing token-env") + token, errToken := envValueRequired(item.TokenEnv, "token-env") + if errToken != nil { + return errToken } headers.Set("Authorization", "Bearer "+token) default: diff --git a/internal/pluginstore/auth_test.go b/internal/pluginstore/auth_test.go index d672911e..07ea25be 100644 --- a/internal/pluginstore/auth_test.go +++ b/internal/pluginstore/auth_test.go @@ -44,6 +44,24 @@ func TestPluginStoreAuthMatchesURLHostAndPathBoundaries(t *testing.T) { } } +func TestPluginStoreGitHubTokenUsesExplicitTokenEnv(t *testing.T) { + t.Setenv("PLUGIN_STORE_TOKEN", "secret-token") + headers := http.Header{} + auth := []AuthConfig{{ + Match: "https://api.github.com/repos/author-name/sample-provider/releases/", + ApplyTo: []string{RequestKindArtifact}, + Type: AuthTypeGitHubToken, + TokenEnv: "PLUGIN_STORE_TOKEN", + }} + + if errAuth := applyPluginStoreAuth(headers, auth, "https://api.github.com/repos/author-name/sample-provider/releases/assets/1", RequestKindArtifact); errAuth != nil { + t.Fatalf("applyPluginStoreAuth() error = %v", errAuth) + } + if gotAuth := headers.Get("Authorization"); gotAuth != "Bearer secret-token" { + t.Fatalf("Authorization = %q, want Bearer secret-token", gotAuth) + } +} + func TestPluginAuthConfiguredCoversInstallRequestKinds(t *testing.T) { t.Setenv("PLUGIN_STORE_TOKEN", "secret-token") diff --git a/internal/pluginstore/github.go b/internal/pluginstore/github.go index fbb27f06..2db6299e 100644 --- a/internal/pluginstore/github.go +++ b/internal/pluginstore/github.go @@ -7,7 +7,6 @@ import ( "io" "net/http" "net/url" - "os" "strings" "github.com/router-for-me/CLIProxyAPI/v7/internal/httpfetch" @@ -33,6 +32,7 @@ type Release struct { } type ReleaseAsset struct { + APIURL string `json:"url"` Name string `json:"name"` BrowserDownloadURL string `json:"browser_download_url"` } @@ -114,10 +114,25 @@ func ReleaseVersion(release Release) (string, error) { } 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) + downloadURL := strings.TrimSpace(asset.BrowserDownloadURL) + apiURL := strings.TrimSpace(asset.APIURL) + if downloadURL == "" || c.releaseAssetAPIAuthenticated(apiURL) { + if apiURL != "" { + downloadURL = apiURL + } } - return c.get(ctx, asset.BrowserDownloadURL, "application/octet-stream", RequestKindArtifact, 0) + if downloadURL == "" { + return nil, fmt.Errorf("asset %q missing download url", asset.Name) + } + return c.get(ctx, downloadURL, "application/octet-stream", RequestKindArtifact, 0) +} + +func (c Client) releaseAssetAPIAuthenticated(apiURL string) bool { + apiURL = strings.TrimSpace(apiURL) + if apiURL == "" { + return false + } + return AuthConfigured(c.Auth, apiURL, RequestKindArtifact) } func (c Client) get(ctx context.Context, requestURL string, accept string, kind string, maxSize int64) ([]byte, error) { @@ -133,11 +148,6 @@ func (c Client) get(ctx context.Context, requestURL string, accept string, kind if errAuth := applyPluginStoreAuth(headers, c.Auth, currentURL, kind); errAuth != nil { return nil, errAuth } - if headers.Get("Authorization") == "" { - if token := gitHubAPIToken(currentURL); token != "" { - headers.Set("Authorization", "Bearer "+token) - } - } resp, errDo := pluginStoreGetNoRedirect(ctx, c.httpClient(), currentURL, headers) if errDo != nil { return nil, errDo @@ -160,20 +170,6 @@ func (c Client) get(ctx context.Context, requestURL string, accept string, kind } } -// 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 { if c.HTTPClient != nil { return c.HTTPClient diff --git a/internal/pluginstore/install_test.go b/internal/pluginstore/install_test.go index 576c83ee..24358f62 100644 --- a/internal/pluginstore/install_test.go +++ b/internal/pluginstore/install_test.go @@ -319,8 +319,16 @@ func TestInstallUsesLatestReleaseVersion(t *testing.T) { "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"} + { + "name": "` + archiveName + `", + "url": "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/assets/1", + "browser_download_url": "https://downloads.example/` + archiveName + `" + }, + { + "name": "checksums.txt", + "url": "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/assets/2", + "browser_download_url": "https://downloads.example/checksums.txt" + } ] }`), "https://downloads.example/" + archiveName: archiveData, @@ -347,6 +355,82 @@ func TestInstallUsesLatestReleaseVersion(t *testing.T) { } } +func TestDownloadAssetFallsBackToReleaseAssetAPIURLWhenBrowserDownloadURLEmpty(t *testing.T) { + apiURL := "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/assets/1" + client := Client{HTTPClient: mapHTTPDoer{ + apiURL: []byte("artifact-data"), + }} + + data, errDownload := client.DownloadAsset(context.Background(), ReleaseAsset{ + Name: "sample-provider_0.2.0_darwin_arm64.zip", + APIURL: apiURL, + }) + if errDownload != nil { + t.Fatalf("DownloadAsset() error = %v", errDownload) + } + if string(data) != "artifact-data" { + t.Fatalf("DownloadAsset() = %q, want artifact-data", data) + } +} + +func TestDownloadAssetUsesAPIURLWhenAuthMatchesArtifact(t *testing.T) { + t.Setenv("PLUGIN_STORE_TOKEN", "secret-token") + apiURL := "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/assets/1" + client := Client{ + HTTPClient: authCheckingHTTPDoer{ + url: apiURL, + wantAuth: "Bearer secret-token", + responseBytes: []byte("artifact-data"), + }, + Auth: []AuthConfig{{ + Match: "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/", + ApplyTo: []string{RequestKindArtifact}, + Type: AuthTypeBearer, + TokenEnv: "PLUGIN_STORE_TOKEN", + }}, + } + + data, errDownload := client.DownloadAsset(context.Background(), ReleaseAsset{ + Name: "sample-provider_0.2.0_darwin_arm64.zip", + APIURL: apiURL, + BrowserDownloadURL: "https://downloads.example/sample-provider.zip", + }) + if errDownload != nil { + t.Fatalf("DownloadAsset() error = %v", errDownload) + } + if string(data) != "artifact-data" { + t.Fatalf("DownloadAsset() = %q, want artifact-data", data) + } +} + +func TestDownloadAssetUsesBrowserDownloadURLWithUnrelatedAuth(t *testing.T) { + t.Setenv("PLUGIN_STORE_TOKEN", "secret-token") + browserURL := "https://downloads.example/sample-provider.zip" + client := Client{ + HTTPClient: mapHTTPDoer{ + browserURL: []byte("artifact-data"), + }, + Auth: []AuthConfig{{ + Match: "https://registry.example/", + ApplyTo: []string{RequestKindRegistry}, + Type: AuthTypeBearer, + TokenEnv: "PLUGIN_STORE_TOKEN", + }}, + } + + data, errDownload := client.DownloadAsset(context.Background(), ReleaseAsset{ + Name: "sample-provider_0.2.0_darwin_arm64.zip", + APIURL: "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/assets/1", + BrowserDownloadURL: browserURL, + }) + if errDownload != nil { + t.Fatalf("DownloadAsset() error = %v", errDownload) + } + if string(data) != "artifact-data" { + t.Fatalf("DownloadAsset() = %q, want artifact-data", data) + } +} + func TestInstallVersionUsesPinnedReleaseTag(t *testing.T) { t.Parallel()