Merge pull request #190 from WJQSERVER-STUDIO/feat/perf-hotpath-pass-1

perf(proxy): optimize hot request paths
This commit is contained in:
WJQSERVER 2026-04-13 08:21:21 +08:00 committed by GitHub
commit a34af8a3ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 501 additions and 51 deletions

24
main.go
View file

@ -404,6 +404,8 @@ func main() {
setupApi(cfg, r, version) setupApi(cfg, r, version)
setupPages(cfg, r) setupPages(cfg, r)
r.SetRedirectTrailingSlash(false) r.SetRedirectTrailingSlash(false)
routingHandler := proxy.RoutingHandler(cfg)
noRouteHandler := proxy.NoRouteHandler(cfg)
r.GET("/github.com/:user/:repo/releases/*filepath", func(c *touka.Context) { r.GET("/github.com/:user/:repo/releases/*filepath", func(c *touka.Context) {
// 规范化路径: 移除前导斜杠, 简化后续处理 // 规范化路径: 移除前导斜杠, 简化后续处理
@ -433,7 +435,7 @@ func main() {
// 根据匹配结果执行最终操作 // 根据匹配结果执行最终操作
if isValidDownload { if isValidDownload {
c.Set("matcher", "releases") c.Set("matcher", "releases")
proxy.RoutingHandler(cfg)(c) routingHandler(c)
} else { } else {
// 任何不符合下载链接格式的 'releases' 路径都被视为浏览页面并拒绝 // 任何不符合下载链接格式的 'releases' 路径都被视为浏览页面并拒绝
proxy.ErrorPage(c, proxy.NewErrorWithStatusLookup(400, "unsupported releases page, only download links are allowed")) proxy.ErrorPage(c, proxy.NewErrorWithStatusLookup(400, "unsupported releases page, only download links are allowed"))
@ -443,45 +445,45 @@ func main() {
r.GET("/github.com/:user/:repo/archive/*filepath", func(c *touka.Context) { r.GET("/github.com/:user/:repo/archive/*filepath", func(c *touka.Context) {
c.Set("matcher", "releases") c.Set("matcher", "releases")
proxy.RoutingHandler(cfg)(c) routingHandler(c)
}) })
r.GET("/github.com/:user/:repo/blob/*filepath", func(c *touka.Context) { r.GET("/github.com/:user/:repo/blob/*filepath", func(c *touka.Context) {
c.Set("matcher", "blob") c.Set("matcher", "blob")
proxy.RoutingHandler(cfg)(c) routingHandler(c)
}) })
r.GET("/github.com/:user/:repo/raw/*filepath", func(c *touka.Context) { r.GET("/github.com/:user/:repo/raw/*filepath", func(c *touka.Context) {
c.Set("matcher", "raw") c.Set("matcher", "raw")
proxy.RoutingHandler(cfg)(c) routingHandler(c)
}) })
r.GET("/github.com/:user/:repo/info/*filepath", func(c *touka.Context) { r.GET("/github.com/:user/:repo/info/*filepath", func(c *touka.Context) {
c.Set("matcher", "clone") c.Set("matcher", "clone")
proxy.RoutingHandler(cfg)(c) routingHandler(c)
}) })
r.GET("/github.com/:user/:repo/git-upload-pack", func(c *touka.Context) { r.GET("/github.com/:user/:repo/git-upload-pack", func(c *touka.Context) {
c.Set("matcher", "clone") c.Set("matcher", "clone")
proxy.RoutingHandler(cfg)(c) routingHandler(c)
}) })
r.POST("/github.com/:user/:repo/git-upload-pack", func(c *touka.Context) { r.POST("/github.com/:user/:repo/git-upload-pack", func(c *touka.Context) {
c.Set("matcher", "clone") c.Set("matcher", "clone")
proxy.RoutingHandler(cfg)(c) routingHandler(c)
}) })
r.GET("/raw.githubusercontent.com/:user/:repo/*filepath", func(c *touka.Context) { r.GET("/raw.githubusercontent.com/:user/:repo/*filepath", func(c *touka.Context) {
c.Set("matcher", "raw") c.Set("matcher", "raw")
proxy.RoutingHandler(cfg)(c) routingHandler(c)
}) })
r.GET("/gist.githubusercontent.com/:user/*filepath", func(c *touka.Context) { r.GET("/gist.githubusercontent.com/:user/*filepath", func(c *touka.Context) {
c.Set("matcher", "gist") c.Set("matcher", "gist")
proxy.NoRouteHandler(cfg)(c) noRouteHandler(c)
}) })
r.ANY("/api.github.com/repos/:user/:repo/*filepath", func(c *touka.Context) { r.ANY("/api.github.com/repos/:user/:repo/*filepath", func(c *touka.Context) {
c.Set("matcher", "api") c.Set("matcher", "api")
proxy.RoutingHandler(cfg)(c) routingHandler(c)
}) })
r.ANY("/v2/*path", r.ANY("/v2/*path",
@ -497,7 +499,7 @@ func main() {
}) })
r.NoRoute(func(c *touka.Context) { r.NoRoute(func(c *touka.Context) {
proxy.NoRouteHandler(cfg)(c) noRouteHandler(c)
}) })
fmt.Printf("GHProxy Version: %s\n", version) fmt.Printf("GHProxy Version: %s\n", version)

View file

@ -3,7 +3,6 @@ package proxy
import ( import (
"fmt" "fmt"
"ghproxy/config" "ghproxy/config"
"regexp"
"strings" "strings"
"github.com/infinite-iroha/touka" "github.com/infinite-iroha/touka"
@ -31,29 +30,34 @@ func buildProxyPath(path, matcher string) string {
return sb.String() return sb.String()
} }
var re = regexp.MustCompile(`^(http:|https:)?/?/?(.*)`) // 匹配http://或https://开头的路径 func normalizeProxyPath(rawPath string) (string, bool) {
path := strings.TrimLeft(rawPath, "/")
switch {
case strings.HasPrefix(path, "https:"):
path = path[len("https:"):]
case strings.HasPrefix(path, "http:"):
path = path[len("http:"):]
}
path = strings.TrimLeft(path, "/")
return path, path != ""
}
func NoRouteHandler(cfg *config.Config) touka.HandlerFunc { func NoRouteHandler(cfg *config.Config) touka.HandlerFunc {
return func(c *touka.Context) { return func(c *touka.Context) {
var ctx = c.Request.Context() var ctx = c.Request.Context()
var shoudBreak bool var shoudBreak bool
var ( path, ok := normalizeProxyPath(c.GetRequestURI())
rawPath string
matches []string
)
rawPath = strings.TrimPrefix(c.GetRequestURI(), "/") // 去掉前缀/
matches = re.FindStringSubmatch(rawPath) // 匹配路径
// 匹配路径错误处理 // 匹配路径错误处理
if len(matches) < 3 { if !ok {
c.Warnf("%s %s %s %s %s Invalid URL", c.ClientIP(), c.Request.Method, c.Request.URL.Path, c.UserAgent(), c.Request.Proto) c.Warnf("%s %s %s %s %s Invalid URL", c.ClientIP(), c.Request.Method, c.Request.URL.Path, c.UserAgent(), c.Request.Proto)
ErrorPage(c, NewErrorWithStatusLookup(400, fmt.Sprintf("Invalid URL Format: %s", c.GetRequestURI()))) ErrorPage(c, NewErrorWithStatusLookup(400, fmt.Sprintf("Invalid URL Format: %s", c.GetRequestURI())))
return return
} }
path := matches[2]
var matcherErr *GHProxyErrors var matcherErr *GHProxyErrors
user, repo, matcher, matcherErr := Matcher("https://"+path, cfg) user, repo, matcher, matcherErr := Matcher("https://"+path, cfg)
if matcherErr != nil { if matcherErr != nil {
@ -61,7 +65,7 @@ func NoRouteHandler(cfg *config.Config) touka.HandlerFunc {
return return
} }
rawPath = buildProxyPath(path, matcher) rawPath := buildProxyPath(path, matcher)
shoudBreak = listCheck(cfg, c, user, repo, rawPath) shoudBreak = listCheck(cfg, c, user, repo, rawPath)
if shoudBreak { if shoudBreak {

192
proxy/hotpath_test.go Normal file
View file

@ -0,0 +1,192 @@
package proxy
import (
"net/http"
"net/http/httptest"
"reflect"
"testing"
"ghproxy/config"
"github.com/infinite-iroha/touka"
)
func TestNormalizeProxyPath(t *testing.T) {
testCases := []struct {
name string
rawPath string
expected string
expectValid bool
}{
{name: "Plain host path", rawPath: "/github.com/owner/repo", expected: "github.com/owner/repo", expectValid: true},
{name: "HTTPS URL", rawPath: "/https://github.com/owner/repo", expected: "github.com/owner/repo", expectValid: true},
{name: "HTTP URL", rawPath: "http://github.com/owner/repo", expected: "github.com/owner/repo", expectValid: true},
{name: "Scheme with single slash", rawPath: "https:/github.com/owner/repo", expected: "github.com/owner/repo", expectValid: true},
{name: "Extra leading slashes", rawPath: "////github.com/owner/repo", expected: "github.com/owner/repo", expectValid: true},
{name: "Empty path", rawPath: "", expectValid: false},
{name: "Slash only", rawPath: "////", expectValid: false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got, ok := normalizeProxyPath(tc.rawPath)
if ok != tc.expectValid {
t.Fatalf("valid = %v, want %v", ok, tc.expectValid)
}
if got != tc.expected {
t.Fatalf("path = %q, want %q", got, tc.expected)
}
})
}
}
func TestCopyHeaderFiltered(t *testing.T) {
src := http.Header{
"Accept": {"text/plain"},
"Connection": {"keep-alive"},
"X-Test": {"one", "two"},
"Accept-Encoding": {"gzip"},
}
dst := make(http.Header)
copyHeaderFiltered(dst, src, reqHeadersToRemove)
if got := dst.Values("Accept"); !reflect.DeepEqual(got, []string{"text/plain"}) {
t.Fatalf("Accept = %v, want [text/plain]", got)
}
if got := dst.Values("X-Test"); !reflect.DeepEqual(got, []string{"one", "two"}) {
t.Fatalf("X-Test = %v, want [one two]", got)
}
if got := dst.Values("Connection"); len(got) != 0 {
t.Fatalf("Connection should be filtered, got %v", got)
}
if got := dst.Values("Accept-Encoding"); len(got) != 0 {
t.Fatalf("Accept-Encoding should be filtered, got %v", got)
}
}
func TestCopyHeaderFiltered_CanonicalizesDenylist(t *testing.T) {
src := http.Header{
"Cf-Ipcountry": {"CN"},
"Cf-Ray": {"abc123"},
"Cf-Ew-Via": {"edge"},
"X-Forwarded-For": {"127.0.0.1"},
}
dst := make(http.Header)
copyHeaderFiltered(dst, src, reqHeadersToRemove)
if got := dst.Values("Cf-Ipcountry"); len(got) != 0 {
t.Fatalf("Cf-Ipcountry should be filtered, got %v", got)
}
if got := dst.Values("Cf-Ray"); len(got) != 0 {
t.Fatalf("Cf-Ray should be filtered, got %v", got)
}
if got := dst.Values("Cf-Ew-Via"); len(got) != 0 {
t.Fatalf("Cf-Ew-Via should be filtered, got %v", got)
}
if got := dst.Values("X-Forwarded-For"); !reflect.DeepEqual(got, []string{"127.0.0.1"}) {
t.Fatalf("X-Forwarded-For = %v, want [127.0.0.1]", got)
}
}
func TestCopyHeaderFiltered_AllowsAllWhenDenylistEmpty(t *testing.T) {
src := http.Header{
"X-Test": {"one", "two"},
}
dst := make(http.Header)
copyHeaderFiltered(dst, src, nil)
if got := dst.Values("X-Test"); !reflect.DeepEqual(got, []string{"one", "two"}) {
t.Fatalf("X-Test = %v, want [one two]", got)
}
}
func TestBuildProxyPath(t *testing.T) {
testCases := []struct {
name string
path string
matcher string
expected string
}{
{
name: "Blob path rewrites to raw host",
path: "github.com/owner/repo/blob/main/file.go",
matcher: "blob",
expected: "https://raw.githubusercontent.com/owner/repo/main/file.go",
},
{
name: "Non blob path keeps host",
path: "raw.githubusercontent.com/owner/repo/main/file.go",
matcher: "raw",
expected: "https://raw.githubusercontent.com/owner/repo/main/file.go",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
if got := buildProxyPath(tc.path, tc.matcher); got != tc.expected {
t.Fatalf("buildProxyPath() = %q, want %q", got, tc.expected)
}
})
}
}
func TestNoRouteHandler_InvalidURI_ReturnsBadRequest(t *testing.T) {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "http://client.example/", nil)
req.RequestURI = "/"
ctx, _ := touka.CreateTestContextWithRequest(recorder, req)
NoRouteHandler(&config.Config{})(ctx)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusBadRequest)
}
if body := recorder.Body.String(); body == "" {
t.Fatal("expected error response body to be written")
}
}
func TestNoRouteHandler_NormalizesAbsoluteRequestURIForAPI(t *testing.T) {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "http://client.example/placeholder", nil)
req.RequestURI = "/https://api.github.com/repos/WJQSERVER-STUDIO/ghproxy/releases?per_page=1"
ctx, _ := touka.CreateTestContextWithRequest(recorder, req)
cfg := &config.Config{}
NoRouteHandler(cfg)(ctx)
if recorder.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusForbidden)
}
if body := recorder.Body.String(); body == "" {
t.Fatal("expected error response body to be written")
}
}
func BenchmarkNormalizeProxyPath(b *testing.B) {
for i := 0; i < b.N; i++ {
_, _ = normalizeProxyPath("/https://github.com/WJQSERVER-STUDIO/ghproxy/releases/download/v1.0.0/asset.tar.gz")
}
}
func BenchmarkCopyHeaderFiltered(b *testing.B) {
src := http.Header{
"Accept": {"text/plain"},
"Accept-Encoding": {"gzip"},
"Connection": {"keep-alive"},
"User-Agent": {"curl/8.0.1"},
"X-Test": {"one", "two"},
"CF-Connecting-IP": {"127.0.0.1"},
"X-Forwarded-For": {"127.0.0.1"},
"Transfer-Encoding": {"chunked"},
}
b.ReportAllocs()
for i := 0; i < b.N; i++ {
dst := make(http.Header)
copyHeaderFiltered(dst, src, reqHeadersToRemove)
}
}

View file

@ -39,6 +39,9 @@ func initHTTPClient(cfg *config.Config) *httpc.Client {
switch cfg.Httpc.Mode { switch cfg.Httpc.Mode {
case "auto", "": case "auto", "":
tr = &http.Transport{ tr = &http.Transport{
MaxIdleConns: cfg.Httpc.MaxIdleConns,
MaxConnsPerHost: cfg.Httpc.MaxConnsPerHost,
MaxIdleConnsPerHost: cfg.Httpc.MaxIdleConnsPerHost,
IdleConnTimeout: 30 * time.Second, IdleConnTimeout: 30 * time.Second,
WriteBufferSize: 32 * 1024, // 32KB WriteBufferSize: 32 * 1024, // 32KB
ReadBufferSize: 32 * 1024, // 32KB ReadBufferSize: 32 * 1024, // 32KB
@ -77,6 +80,9 @@ func initGitHTTPClient(cfg *config.Config) {
switch cfg.Httpc.Mode { switch cfg.Httpc.Mode {
case "auto", "": case "auto", "":
gittr = &http.Transport{ gittr = &http.Transport{
MaxIdleConns: cfg.Httpc.MaxIdleConns,
MaxConnsPerHost: cfg.Httpc.MaxConnsPerHost,
MaxIdleConnsPerHost: cfg.Httpc.MaxIdleConnsPerHost,
IdleConnTimeout: 30 * time.Second, IdleConnTimeout: 30 * time.Second,
WriteBufferSize: 32 * 1024, // 32KB WriteBufferSize: 32 * 1024, // 32KB
ReadBufferSize: 32 * 1024, // 32KB ReadBufferSize: 32 * 1024, // 32KB

64
proxy/httpc_test.go Normal file
View file

@ -0,0 +1,64 @@
package proxy
import (
"ghproxy/config"
"testing"
)
func TestInitHTTPClient_AutoModeUsesConfiguredPoolSizes(t *testing.T) {
oldTr, oldClient := tr, client
t.Cleanup(func() {
tr = oldTr
client = oldClient
})
cfg := &config.Config{}
cfg.Httpc.Mode = "auto"
cfg.Httpc.MaxIdleConns = 123
cfg.Httpc.MaxIdleConnsPerHost = 45
cfg.Httpc.MaxConnsPerHost = 67
initHTTPClient(cfg)
if tr == nil {
t.Fatal("transport was not initialized")
}
if tr.MaxIdleConns != 123 {
t.Fatalf("MaxIdleConns = %d, want 123", tr.MaxIdleConns)
}
if tr.MaxIdleConnsPerHost != 45 {
t.Fatalf("MaxIdleConnsPerHost = %d, want 45", tr.MaxIdleConnsPerHost)
}
if tr.MaxConnsPerHost != 67 {
t.Fatalf("MaxConnsPerHost = %d, want 67", tr.MaxConnsPerHost)
}
}
func TestInitGitHTTPClient_AutoModeUsesConfiguredPoolSizes(t *testing.T) {
oldGitTr, oldGitClient := gittr, gitclient
t.Cleanup(func() {
gittr = oldGitTr
gitclient = oldGitClient
})
cfg := &config.Config{}
cfg.Httpc.Mode = "auto"
cfg.Httpc.MaxIdleConns = 98
cfg.Httpc.MaxIdleConnsPerHost = 76
cfg.Httpc.MaxConnsPerHost = 54
initGitHTTPClient(cfg)
if gittr == nil {
t.Fatal("git transport was not initialized")
}
if gittr.MaxIdleConns != 98 {
t.Fatalf("MaxIdleConns = %d, want 98", gittr.MaxIdleConns)
}
if gittr.MaxIdleConnsPerHost != 76 {
t.Fatalf("MaxIdleConnsPerHost = %d, want 76", gittr.MaxIdleConnsPerHost)
}
if gittr.MaxConnsPerHost != 54 {
t.Fatalf("MaxConnsPerHost = %d, want 54", gittr.MaxConnsPerHost)
}
}

View file

@ -116,11 +116,19 @@ func Matcher(rawPath string, cfg *config.Config) (string, string, string, *GHPro
// 匹配 "https://raw.githubusercontent.com/" // 匹配 "https://raw.githubusercontent.com/"
if strings.HasPrefix(rawPath, rawPrefix) { if strings.HasPrefix(rawPath, rawPrefix) {
remaining := rawPath[rawPrefixLen:] remaining := rawPath[rawPrefixLen:]
parts := strings.SplitN(remaining, "/", 3) i := strings.IndexByte(remaining, '/')
if len(parts) < 3 { if i <= 0 {
return "", "", "", NewErrorWithStatusLookup(400, "malformed raw url: path too short") return "", "", "", NewErrorWithStatusLookup(400, "malformed raw url: path too short")
} }
return parts[0], parts[1], "raw", nil user := remaining[:i]
remaining = remaining[i+1:]
i = strings.IndexByte(remaining, '/')
if i <= 0 || i == len(remaining)-1 {
return "", "", "", NewErrorWithStatusLookup(400, "malformed raw url: path too short")
}
return user, remaining[:i], "raw", nil
} }
// 匹配 "https://gist.github.com/" 或 "https://gist.githubusercontent.com/" // 匹配 "https://gist.github.com/" 或 "https://gist.githubusercontent.com/"
@ -132,11 +140,16 @@ func Matcher(rawPath string, cfg *config.Config) (string, string, string, *GHPro
} else { } else {
remaining = rawPath[gistContentPrefixLen:] remaining = rawPath[gistContentPrefixLen:]
} }
parts := strings.SplitN(remaining, "/", 2) if remaining == "" {
if len(parts) == 0 || parts[0] == "" {
return "", "", "", NewErrorWithStatusLookup(400, "malformed gist url: missing user") return "", "", "", NewErrorWithStatusLookup(400, "malformed gist url: missing user")
} }
return parts[0], "", "gist", nil if i := strings.IndexByte(remaining, '/'); i != -1 {
if i == 0 {
return "", "", "", NewErrorWithStatusLookup(400, "malformed gist url: missing user")
}
remaining = remaining[:i]
}
return remaining, "", "gist", nil
} }
// 匹配 "https://api.github.com/" // 匹配 "https://api.github.com/"
@ -147,15 +160,35 @@ func Matcher(rawPath string, cfg *config.Config) (string, string, string, *GHPro
remaining := rawPath[apiPrefixLen:] remaining := rawPath[apiPrefixLen:]
var user, repo string var user, repo string
if strings.HasPrefix(remaining, "repos/") { if strings.HasPrefix(remaining, "repos/") {
parts := strings.SplitN(remaining[6:], "/", 3) remaining = remaining[6:]
if len(parts) >= 2 { if q := strings.IndexByte(remaining, '?'); q != -1 {
user = parts[0] remaining = remaining[:q]
repo = parts[1] }
if remaining != "" && !strings.ContainsRune(remaining, '/') {
user = remaining
return user, "", "api", nil
}
i := strings.IndexByte(remaining, '/')
if i > 0 {
user = remaining[:i]
rest := remaining[i+1:]
if j := strings.IndexByte(rest, '/'); j != -1 {
repo = rest[:j]
} else {
repo = rest
}
} }
} else if strings.HasPrefix(remaining, "users/") { } else if strings.HasPrefix(remaining, "users/") {
parts := strings.SplitN(remaining[6:], "/", 2) remaining = remaining[6:]
if len(parts) >= 1 { if q := strings.IndexByte(remaining, '?'); q != -1 {
user = parts[0] remaining = remaining[:q]
}
if remaining != "" {
if i := strings.IndexByte(remaining, '/'); i != -1 {
user = remaining[:i]
} else {
user = remaining
}
} }
} }
return user, repo, "api", nil return user, repo, "api", nil

View file

@ -99,12 +99,24 @@ func TestMatcher_Compatibility(t *testing.T) {
config: cfgWithAuth, config: cfgWithAuth,
expectedUser: "owner", expectedRepo: "repo", expectedMatcher: "raw", expectedUser: "owner", expectedRepo: "repo", expectedMatcher: "raw",
}, },
{
name: "Malformed Raw Path (missing branch)",
rawPath: "https://raw.githubusercontent.com/owner/repo",
config: cfgWithAuth,
expectError: true, expectedErrCode: 400,
},
{ {
name: "Gist Path", name: "Gist Path",
rawPath: "https://gist.github.com/user/abcdef1234567890", rawPath: "https://gist.github.com/user/abcdef1234567890",
config: cfgWithAuth, config: cfgWithAuth,
expectedUser: "user", expectedRepo: "", expectedMatcher: "gist", expectedUser: "user", expectedRepo: "", expectedMatcher: "gist",
}, },
{
name: "Gist Path (user only)",
rawPath: "https://gist.github.com/user",
config: cfgWithAuth,
expectedUser: "user", expectedRepo: "", expectedMatcher: "gist",
},
{ {
name: "Gist UserContent Path", name: "Gist UserContent Path",
rawPath: "https://gist.githubusercontent.com/user/abcdef1234567890", rawPath: "https://gist.githubusercontent.com/user/abcdef1234567890",
@ -135,6 +147,30 @@ func TestMatcher_Compatibility(t *testing.T) {
config: cfgApiForceAllowed, // Auth disabled, but force allowed config: cfgApiForceAllowed, // Auth disabled, but force allowed
expectedUser: "owner", expectedRepo: "repo", expectedMatcher: "api", expectedUser: "owner", expectedRepo: "repo", expectedMatcher: "api",
}, },
{
name: "API Repos Path (missing repo)",
rawPath: "https://api.github.com/repos/owner",
config: cfgWithAuth,
expectedUser: "owner", expectedRepo: "", expectedMatcher: "api",
},
{
name: "API Repos Path (trailing slash)",
rawPath: "https://api.github.com/repos/owner/",
config: cfgWithAuth,
expectedUser: "owner", expectedRepo: "", expectedMatcher: "api",
},
{
name: "API Repos Path (missing repo with query)",
rawPath: "https://api.github.com/repos/owner?per_page=1",
config: cfgWithAuth,
expectedUser: "owner", expectedRepo: "", expectedMatcher: "api",
},
{
name: "API Users Path (exact user)",
rawPath: "https://api.github.com/users/someuser",
config: cfgWithAuth,
expectedUser: "someuser", expectedRepo: "", expectedMatcher: "api",
},
{ {
name: "Malformed GH Path (no repo)", name: "Malformed GH Path (no repo)",
rawPath: "https://github.com/owner/", rawPath: "https://github.com/owner/",

View file

@ -0,0 +1,88 @@
package proxy
import (
"net/http"
"testing"
"ghproxy/config"
"github.com/infinite-iroha/touka"
)
var benchmarkHeaderSource = http.Header{
"Accept": {"text/plain"},
"Accept-Encoding": {"gzip"},
"Connection": {"keep-alive"},
"User-Agent": {"curl/8.0.1"},
"X-Test": {"one", "two"},
"CF-Connecting-IP": {"127.0.0.1"},
"X-Forwarded-For": {"127.0.0.1"},
"Transfer-Encoding": {"chunked"},
}
func BenchmarkMatcherGithubRelease(b *testing.B) {
cfg := &config.Config{
Auth: config.AuthConfig{Enabled: true, Method: "header", ForceAllowApi: false},
}
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _, _, _ = Matcher("https://github.com/WJQSERVER-STUDIO/ghproxy/releases/download/v1.0.0/asset.tar.gz", cfg)
}
}
func BenchmarkMatcherRaw(b *testing.B) {
cfg := &config.Config{
Auth: config.AuthConfig{Enabled: true, Method: "header", ForceAllowApi: false},
}
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _, _, _ = Matcher("https://raw.githubusercontent.com/WJQSERVER-STUDIO/ghproxy/main/README.md", cfg)
}
}
func BenchmarkMatcherGist(b *testing.B) {
cfg := &config.Config{
Auth: config.AuthConfig{Enabled: true, Method: "header", ForceAllowApi: false},
}
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _, _, _ = Matcher("https://gist.githubusercontent.com/user/abcdef1234567890/raw/file.txt", cfg)
}
}
func BenchmarkMatcherAPI(b *testing.B) {
cfg := &config.Config{
Auth: config.AuthConfig{Enabled: true, Method: "header", ForceAllowApi: false},
}
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _, _, _ = Matcher("https://api.github.com/repos/WJQSERVER-STUDIO/ghproxy/releases", cfg)
}
}
func BenchmarkSetRequestHeadersClone(b *testing.B) {
ctx := &touka.Context{Request: &http.Request{Header: benchmarkHeaderSource}}
cfg := &config.Config{}
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := &http.Request{Header: make(http.Header)}
setRequestHeaders(ctx, req, cfg, "clone")
}
}
func BenchmarkSetRequestHeadersRawCustom(b *testing.B) {
ctx := &touka.Context{Request: &http.Request{Header: benchmarkHeaderSource}}
cfg := &config.Config{}
cfg.Httpc.UseCustomRawHeaders = true
b.ReportAllocs()
for i := 0; i < b.N; i++ {
req := &http.Request{Header: make(http.Header)}
setRequestHeaders(ctx, req, cfg, "raw")
}
}

View file

@ -60,6 +60,37 @@ func copyHeader(dst, src http.Header) {
} }
} }
func canonicalizeHeaderSet(headers map[string]struct{}) map[string]struct{} {
canonicalized := make(map[string]struct{}, len(headers))
for key := range headers {
canonicalized[http.CanonicalHeaderKey(key)] = struct{}{}
}
return canonicalized
}
func init() {
reqHeadersToRemove = canonicalizeHeaderSet(reqHeadersToRemove)
cloneHeadersToRemove = canonicalizeHeaderSet(cloneHeadersToRemove)
respHeadersToRemove = canonicalizeHeaderSet(respHeadersToRemove)
defaultHeaders = map[string]string{
"Accept": "*/*",
"Accept-Encoding": "",
"Transfer-Encoding": "chunked",
"User-Agent": "GHProxy/1.0",
}
}
func copyHeaderFiltered(dst, src http.Header, denylist map[string]struct{}) {
for k, vv := range src {
if _, denied := denylist[k]; denied {
continue
}
for _, v := range vv {
dst.Add(k, v)
}
}
}
func setRequestHeaders(c *touka.Context, req *http.Request, cfg *config.Config, matcher string) { func setRequestHeaders(c *touka.Context, req *http.Request, cfg *config.Config, matcher string) {
if matcher == "raw" && cfg.Httpc.UseCustomRawHeaders { if matcher == "raw" && cfg.Httpc.UseCustomRawHeaders {
// 使用预定义Header // 使用预定义Header
@ -67,14 +98,8 @@ func setRequestHeaders(c *touka.Context, req *http.Request, cfg *config.Config,
req.Header.Set(key, value) req.Header.Set(key, value)
} }
} else if matcher == "clone" { } else if matcher == "clone" {
copyHeader(req.Header, c.Request.Header) copyHeaderFiltered(req.Header, c.Request.Header, cloneHeadersToRemove)
for key := range cloneHeadersToRemove {
req.Header.Del(key)
}
} else { } else {
copyHeader(req.Header, c.Request.Header) copyHeaderFiltered(req.Header, c.Request.Header, reqHeadersToRemove)
for key := range reqHeadersToRemove {
req.Header.Del(key)
}
} }
} }