Merge pull request #4581 from wucm667/feat/issue-4375-github-release-token

feat: support GitHub token for update checks
This commit is contained in:
Wesley Liddick
2026-07-20 10:26:15 +08:00
committed by GitHub
9 changed files with 298 additions and 10 deletions
@@ -7,6 +7,7 @@ import (
"io"
"log/slog"
"net/http"
"net/url"
"os"
"strings"
"time"
@@ -18,6 +19,7 @@ import (
type githubReleaseClient struct {
httpClient *http.Client
downloadHTTPClient *http.Client
updateGitHubToken string
}
type githubReleaseClientError struct {
@@ -43,6 +45,8 @@ func NewGitHubReleaseClient(proxyURL string, allowDirectOnProxyError bool) servi
}
sharedClient = &http.Client{Timeout: 30 * time.Second}
}
apiClient := cloneHTTPClient(sharedClient)
apiClient.CheckRedirect = githubAPICheckRedirect(apiClient.CheckRedirect)
// 下载客户端需要更长的超时时间
downloadClient, err := httpclient.GetClient(httpclient.Options{
@@ -56,13 +60,50 @@ func NewGitHubReleaseClient(proxyURL string, allowDirectOnProxyError bool) servi
}
downloadClient = &http.Client{Timeout: 10 * time.Minute}
}
downloadClient = cloneHTTPClient(downloadClient)
return &githubReleaseClient{
httpClient: sharedClient,
httpClient: apiClient,
downloadHTTPClient: downloadClient,
updateGitHubToken: os.Getenv("UPDATE_GITHUB_TOKEN"),
}
}
func cloneHTTPClient(client *http.Client) *http.Client {
cloned := *client
return &cloned
}
func isGitHubAPIURL(url *url.URL) bool {
return url != nil && strings.EqualFold(url.Scheme, "https") && url.User == nil &&
strings.EqualFold(url.Host, "api.github.com")
}
func githubAPICheckRedirect(previous func(*http.Request, []*http.Request) error) func(*http.Request, []*http.Request) error {
return func(req *http.Request, via []*http.Request) error {
if !isGitHubAPIURL(req.URL) {
req.Header.Del("Authorization")
}
if previous != nil {
return previous(req, via)
}
return nil
}
}
func (c *githubReleaseClient) newAPIRequest(ctx context.Context, url string) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github.v3+json")
req.Header.Set("User-Agent", "Sub2API-Updater")
if c.updateGitHubToken != "" && isGitHubAPIURL(req.URL) {
req.Header.Set("Authorization", "Bearer "+c.updateGitHubToken)
}
return req, nil
}
func (c *githubReleaseClientError) FetchLatestRelease(ctx context.Context, repo string) (*service.GitHubRelease, error) {
return nil, c.err
}
@@ -82,12 +123,10 @@ func (c *githubReleaseClientError) FetchChecksumFile(ctx context.Context, url st
func (c *githubReleaseClient) FetchLatestRelease(ctx context.Context, repo string) (*service.GitHubRelease, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
req, err := c.newAPIRequest(ctx, url)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github.v3+json")
req.Header.Set("User-Agent", "Sub2API-Updater")
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -116,12 +155,10 @@ func (c *githubReleaseClient) FetchRecentReleases(ctx context.Context, repo stri
}
url := fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=%d", repo, perPage)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
req, err := c.newAPIRequest(ctx, url)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github.v3+json")
req.Header.Set("User-Agent", "Sub2API-Updater")
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -3,6 +3,7 @@ package repository
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"os"
@@ -47,6 +48,97 @@ func newTestGitHubReleaseClient() *githubReleaseClient {
}
}
func TestGitHubReleaseClientAPIRequestAuthorization(t *testing.T) {
tests := []struct {
name string
url string
wantAuth string
}{
{name: "exact HTTPS authority", url: "https://api.github.com/repos/test/repo", wantAuth: "Bearer update-secret"},
{name: "HTTP", url: "http://api.github.com/repos/test/repo"},
{name: "subdomain", url: "https://sub.api.github.com/repos/test/repo"},
{name: "userinfo", url: "https://user@api.github.com/repos/test/repo"},
{name: "explicit default port", url: "https://api.github.com:443/repos/test/repo"},
{name: "custom port", url: "https://api.github.com:8443/repos/test/repo"},
{name: "different host", url: "https://github.com/test/repo"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := newTestGitHubReleaseClient()
client.updateGitHubToken = "update-secret"
req, err := client.newAPIRequest(context.Background(), tt.url)
require.NoError(t, err)
require.Equal(t, tt.wantAuth, req.Header.Get("Authorization"))
})
}
client := newTestGitHubReleaseClient()
req, err := client.newAPIRequest(context.Background(), "https://api.github.com/repos/test/repo")
require.NoError(t, err)
require.Empty(t, req.Header.Get("Authorization"))
}
func TestGitHubReleaseClientRedirectAuthorization(t *testing.T) {
tests := []struct {
name string
url string
wantAuth string
}{
{name: "same HTTPS authority", url: "https://api.github.com/redirected", wantAuth: "Bearer update-secret"},
{name: "HTTP", url: "http://api.github.com/redirected"},
{name: "subdomain", url: "https://sub.api.github.com/redirected"},
{name: "userinfo", url: "https://user@api.github.com/redirected"},
{name: "custom port", url: "https://api.github.com:8443/redirected"},
{name: "different host", url: "https://example.com/redirected"},
}
checkRedirect := githubAPICheckRedirect(nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, tt.url, nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer update-secret")
require.NoError(t, checkRedirect(req, nil))
require.Equal(t, tt.wantAuth, req.Header.Get("Authorization"))
})
}
}
func TestGitHubReleaseClientDoesNotAuthorizeDownloads(t *testing.T) {
client := newTestGitHubReleaseClient()
client.updateGitHubToken = "update-secret"
var headers []http.Header
transport := githubReleaseRoundTripFunc(func(req *http.Request) (*http.Response, error) {
headers = append(headers, req.Header.Clone())
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("checksum")),
Request: req,
}, nil
})
client.httpClient.Transport = transport
client.downloadHTTPClient.Transport = transport
dest := filepath.Join(t.TempDir(), "asset")
require.NoError(t, client.DownloadFile(context.Background(), "https://objects.githubusercontent.com/asset", dest, 100))
_, err := client.FetchChecksumFile(context.Background(), "https://github.com/test/repo/releases/download/v1/checksums.txt")
require.NoError(t, err)
require.Len(t, headers, 2)
for _, header := range headers {
require.Empty(t, header.Get("Authorization"))
}
}
type githubReleaseRoundTripFunc func(*http.Request) (*http.Response, error)
func (f githubReleaseRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func (s *GitHubReleaseServiceSuite) SetupTest() {
s.tempDir = s.T().TempDir()
}