feat(github): resolve GitHub token for release checks and asset updates

- Add `util.ResolveGitHubToken` to resolve GitHub API tokens with priority order (`GITHUB_TOKEN`, `github_token`, and `GITSTORE_GIT_TOKEN` for GitHub repositories).
- Set GitHub `Authorization` headers in management version check and asset updater requests when a token is resolved.

Closes: #5189
This commit is contained in:
Luis Pater
2026-08-25 04:22:46 +08:00
parent ba510f85a2
commit e1bf893956
6 changed files with 204 additions and 5 deletions

View File

@@ -36,6 +36,14 @@ type releaseInfo struct {
Name string `json:"name"`
}
func setLatestReleaseRequestHeaders(req *http.Request) {
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", latestReleaseUserAgent)
if token := util.ResolveGitHubToken(); token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
}
// GetLatestVersion returns the latest release version from GitHub without downloading assets.
func (h *Handler) GetLatestVersion(c *gin.Context) {
client := &http.Client{Timeout: 10 * time.Second}
@@ -53,8 +61,7 @@ func (h *Handler) GetLatestVersion(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "request_create_failed", "message": err.Error()})
return
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", latestReleaseUserAgent)
setLatestReleaseRequestHeaders(req)
resp, err := client.Do(req)
if err != nil {

View File

@@ -0,0 +1,46 @@
package management
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestSetLatestReleaseRequestHeaders(t *testing.T) {
tests := []struct {
name string
githubToken string
wantAuthorization string
}{
{
name: "sets GitHub authorization",
githubToken: "release-token",
wantAuthorization: "Bearer release-token",
},
{
name: "omits authorization without token",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("GITHUB_TOKEN", tt.githubToken)
t.Setenv("github_token", "")
t.Setenv("GITSTORE_GIT_TOKEN", "")
t.Setenv("GITSTORE_GIT_URL", "")
req := httptest.NewRequest(http.MethodGet, latestReleaseURL, nil)
setLatestReleaseRequestHeaders(req)
if got := req.Header.Get("Authorization"); got != tt.wantAuthorization {
t.Fatalf("Authorization = %q, want %q", got, tt.wantAuthorization)
}
if got := req.Header.Get("Accept"); got != "application/vnd.github+json" {
t.Fatalf("Accept = %q, want GitHub JSON media type", got)
}
if got := req.Header.Get("User-Agent"); got != latestReleaseUserAgent {
t.Fatalf("User-Agent = %q, want %q", got, latestReleaseUserAgent)
}
})
}
}

View File

@@ -350,9 +350,8 @@ func fetchLatestAsset(ctx context.Context, client *http.Client, releaseURL strin
"Accept": "application/vnd.github+json",
"User-Agent": httpUserAgent,
}
gitURL := strings.ToLower(strings.TrimSpace(os.Getenv("GITSTORE_GIT_URL")))
if tok := strings.TrimSpace(os.Getenv("GITSTORE_GIT_TOKEN")); tok != "" && strings.Contains(gitURL, "github.com") {
headers["Authorization"] = "Bearer " + tok
if token := util.ResolveGitHubToken(); token != "" {
headers["Authorization"] = "Bearer " + token
}
data, err := httpfetch.GetBytes(ctx, client, releaseURL, headers, 0)

View File

@@ -1,11 +1,71 @@
package managementasset
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
)
func TestFetchLatestAssetSetsGitHubAuthorization(t *testing.T) {
t.Setenv("GITHUB_TOKEN", "asset-token")
t.Setenv("github_token", "")
t.Setenv("GITSTORE_GIT_TOKEN", "")
t.Setenv("GITSTORE_GIT_URL", "")
var authorization string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
authorization = req.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"assets":[{"name":"management.html","browser_download_url":"https://example.com/management.html","digest":"sha256:abc123"}]}`))
}))
defer server.Close()
asset, remoteHash, err := fetchLatestAsset(t.Context(), server.Client(), server.URL)
if err != nil {
t.Fatalf("fetchLatestAsset() error = %v", err)
}
if authorization != "Bearer asset-token" {
t.Fatalf("Authorization = %q, want %q", authorization, "Bearer asset-token")
}
if asset == nil || asset.Name != managementAssetName {
t.Fatalf("asset = %#v, want %q", asset, managementAssetName)
}
if remoteHash != "abc123" {
t.Fatalf("remoteHash = %q, want %q", remoteHash, "abc123")
}
}
func TestFetchLatestAssetOmitsAuthorizationWithoutToken(t *testing.T) {
t.Setenv("GITHUB_TOKEN", "")
t.Setenv("github_token", "")
t.Setenv("GITSTORE_GIT_TOKEN", "")
t.Setenv("GITSTORE_GIT_URL", "")
var authorization string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
authorization = req.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"assets":[{"name":"management.html","browser_download_url":"https://example.com/management.html","digest":"sha256:abc123"}]}`))
}))
defer server.Close()
asset, remoteHash, err := fetchLatestAsset(t.Context(), server.Client(), server.URL)
if err != nil {
t.Fatalf("fetchLatestAsset() error = %v", err)
}
if authorization != "" {
t.Fatalf("Authorization = %q, want empty", authorization)
}
if asset == nil || asset.Name != managementAssetName {
t.Fatalf("asset = %#v, want %q", asset, managementAssetName)
}
if remoteHash != "abc123" {
t.Fatalf("remoteHash = %q, want %q", remoteHash, "abc123")
}
}
func TestAutoUpdateSkipReason(t *testing.T) {
tests := []struct {
name string

25
internal/util/github.go Normal file
View File

@@ -0,0 +1,25 @@
package util
import (
"os"
"strings"
)
// ResolveGitHubToken returns the configured GitHub API token in priority order:
// 1. GITHUB_TOKEN
// 2. github_token
// 3. GITSTORE_GIT_TOKEN (only if GITSTORE_GIT_URL points to github.com)
func ResolveGitHubToken() string {
for _, name := range []string{"GITHUB_TOKEN", "github_token"} {
if token := strings.TrimSpace(os.Getenv(name)); token != "" {
return token
}
}
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"))
}

View File

@@ -0,0 +1,62 @@
package util
import "testing"
func TestResolveGitHubToken(t *testing.T) {
tests := []struct {
name string
githubToken string
lowerToken string
gitStoreToken string
gitStoreURL string
want string
}{
{
name: "GITHUB_TOKEN has highest priority",
githubToken: " primary-token ",
lowerToken: "lower-token",
gitStoreToken: "gitstore-token",
gitStoreURL: "https://github.com/example/repo.git",
want: "primary-token",
},
{
name: "lowercase token is second priority",
githubToken: " ",
lowerToken: " lower-token ",
gitStoreToken: "gitstore-token",
gitStoreURL: "https://github.com/example/repo.git",
want: "lower-token",
},
{
name: "Git store token is used for GitHub",
gitStoreToken: " gitstore-token ",
gitStoreURL: "HTTPS://GITHUB.COM/example/repo.git",
want: "gitstore-token",
},
{
name: "Git store token is ignored for other hosts",
gitStoreToken: "gitstore-token",
gitStoreURL: "https://gitlab.com/example/repo.git",
},
{
name: "Git store token is ignored without URL",
gitStoreToken: "gitstore-token",
},
{
name: "no token configured",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("GITHUB_TOKEN", tt.githubToken)
t.Setenv("github_token", tt.lowerToken)
t.Setenv("GITSTORE_GIT_TOKEN", tt.gitStoreToken)
t.Setenv("GITSTORE_GIT_URL", tt.gitStoreURL)
if got := ResolveGitHubToken(); got != tt.want {
t.Fatalf("ResolveGitHubToken() = %q, want %q", got, tt.want)
}
})
}
}