diff --git a/internal/api/handlers/management/config_basic.go b/internal/api/handlers/management/config_basic.go index d87f9e2e5..a0e004f6f 100644 --- a/internal/api/handlers/management/config_basic.go +++ b/internal/api/handlers/management/config_basic.go @@ -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 { diff --git a/internal/api/handlers/management/config_basic_version_test.go b/internal/api/handlers/management/config_basic_version_test.go new file mode 100644 index 000000000..08710c3ab --- /dev/null +++ b/internal/api/handlers/management/config_basic_version_test.go @@ -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) + } + }) + } +} diff --git a/internal/managementasset/updater.go b/internal/managementasset/updater.go index b9f884106..b967d29e6 100644 --- a/internal/managementasset/updater.go +++ b/internal/managementasset/updater.go @@ -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) diff --git a/internal/managementasset/updater_test.go b/internal/managementasset/updater_test.go index 82fdb2912..9de19ee76 100644 --- a/internal/managementasset/updater_test.go +++ b/internal/managementasset/updater_test.go @@ -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 diff --git a/internal/util/github.go b/internal/util/github.go new file mode 100644 index 000000000..17f57cefe --- /dev/null +++ b/internal/util/github.go @@ -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")) +} diff --git a/internal/util/github_test.go b/internal/util/github_test.go new file mode 100644 index 000000000..a4ffcedaf --- /dev/null +++ b/internal/util/github_test.go @@ -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) + } + }) + } +}