Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,8 @@ func main() {
setupApi(cfg, r, version)
setupPages(cfg, r)
r.SetRedirectTrailingSlash(false)
routingHandler := proxy.RoutingHandler(cfg)
noRouteHandler := proxy.NoRouteHandler(cfg)

r.GET("/github.com/:user/:repo/releases/*filepath", func(c *touka.Context) {
// 规范化路径: 移除前导斜杠, 简化后续处理
Expand Down Expand Up @@ -433,7 +435,7 @@ func main() {
// 根据匹配结果执行最终操作
if isValidDownload {
c.Set("matcher", "releases")
proxy.RoutingHandler(cfg)(c)
routingHandler(c)
} else {
// 任何不符合下载链接格式的 'releases' 路径都被视为浏览页面并拒绝
proxy.ErrorPage(c, proxy.NewErrorWithStatusLookup(400, "unsupported releases page, only download links are allowed"))
Expand All @@ -443,45 +445,45 @@ func main() {

r.GET("/github.com/:user/:repo/archive/*filepath", func(c *touka.Context) {
c.Set("matcher", "releases")
proxy.RoutingHandler(cfg)(c)
routingHandler(c)
})

r.GET("/github.com/:user/:repo/blob/*filepath", func(c *touka.Context) {
c.Set("matcher", "blob")
proxy.RoutingHandler(cfg)(c)
routingHandler(c)
})

r.GET("/github.com/:user/:repo/raw/*filepath", func(c *touka.Context) {
c.Set("matcher", "raw")
proxy.RoutingHandler(cfg)(c)
routingHandler(c)
})

r.GET("/github.com/:user/:repo/info/*filepath", func(c *touka.Context) {
c.Set("matcher", "clone")
proxy.RoutingHandler(cfg)(c)
routingHandler(c)
})
r.GET("/github.com/:user/:repo/git-upload-pack", func(c *touka.Context) {
c.Set("matcher", "clone")
proxy.RoutingHandler(cfg)(c)
routingHandler(c)
})
r.POST("/github.com/:user/:repo/git-upload-pack", func(c *touka.Context) {
c.Set("matcher", "clone")
proxy.RoutingHandler(cfg)(c)
routingHandler(c)
})

r.GET("/raw.githubusercontent.com/:user/:repo/*filepath", func(c *touka.Context) {
c.Set("matcher", "raw")
proxy.RoutingHandler(cfg)(c)
routingHandler(c)
})

r.GET("/gist.githubusercontent.com/:user/*filepath", func(c *touka.Context) {
c.Set("matcher", "gist")
proxy.NoRouteHandler(cfg)(c)
noRouteHandler(c)
})

r.ANY("/api.github.com/repos/:user/:repo/*filepath", func(c *touka.Context) {
c.Set("matcher", "api")
proxy.RoutingHandler(cfg)(c)
routingHandler(c)
})

r.ANY("/v2/*path",
Expand All @@ -497,7 +499,7 @@ func main() {
})

r.NoRoute(func(c *touka.Context) {
proxy.NoRouteHandler(cfg)(c)
noRouteHandler(c)
})

fmt.Printf("GHProxy Version: %s\n", version)
Expand Down
4 changes: 2 additions & 2 deletions proxy/chunkreq.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ func ChunkedProxyRequest(ctx context.Context, c *touka.Context, u string, cfg *c
// 处理响应体大小限制

var (
bodySize int
bodySize = -1
contentLength string
sizelimit int
)
Expand Down Expand Up @@ -134,7 +134,7 @@ func ChunkedProxyRequest(ctx context.Context, c *touka.Context, u string, cfg *c

var reader io.Reader

reader, _, err = processLinks(bodyReader, c.Request.Host, cfg, c)
reader, _, err = processLinks(bodyReader, c.Request.Host, cfg, c, bodySize)
c.WriteStream(reader)
if err != nil {
c.Errorf("%s %s %s %s %s Failed to copy response body: %v", c.ClientIP(), c.Request.Method, u, c.UserAgent(), c.Request.Proto, err)
Expand Down
63 changes: 40 additions & 23 deletions proxy/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,50 +3,70 @@ package proxy
import (
"fmt"
"ghproxy/config"
"regexp"
"strings"

"github.com/infinite-iroha/touka"
)

var re = regexp.MustCompile(`^(http:|https:)?/?/?(.*)`) // 匹配http://或https://开头的路径
func buildProxyPath(path, matcher string) string {
var sb strings.Builder
sb.Grow(len(path) + 50)

if matcher == "blob" && strings.HasPrefix(path, "github.com") {
sb.WriteString("https://raw.githubusercontent.com")
pathSegment := path[len("github.com"):]
if i := strings.Index(pathSegment, "/blob/"); i != -1 {
sb.WriteString(pathSegment[:i])
sb.WriteByte('/')
sb.WriteString(pathSegment[i+len("/blob/"):])
} else {
sb.WriteString(pathSegment)
}
return sb.String()
}

sb.WriteString("https://")
sb.WriteString(path)
return sb.String()
}

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 {
return func(c *touka.Context) {
var ctx = c.Request.Context()
var shoudBreak bool

var (
rawPath string
matches []string
)

rawPath = strings.TrimPrefix(c.GetRequestURI(), "/") // 去掉前缀/
matches = re.FindStringSubmatch(rawPath) // 匹配路径
path, ok := normalizeProxyPath(c.GetRequestURI())

// 匹配路径错误处理
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)
ErrorPage(c, NewErrorWithStatusLookup(400, fmt.Sprintf("Invalid URL Format: %s", c.GetRequestURI())))
return
}

// 制作url
rawPath = "https://" + matches[2]

var (
user string
repo string
matcher string
)

var matcherErr *GHProxyErrors
user, repo, matcher, matcherErr = Matcher(rawPath, cfg)
user, repo, matcher, matcherErr := Matcher("https://"+path, cfg)
if matcherErr != nil {
ErrorPage(c, matcherErr)
return
}

rawPath := buildProxyPath(path, matcher)

shoudBreak = listCheck(cfg, c, user, repo, rawPath)
if shoudBreak {
return
Expand All @@ -59,9 +79,6 @@ func NoRouteHandler(cfg *config.Config) touka.HandlerFunc {

// 处理blob/raw路径
if matcher == "blob" {
rawPath = rawPath[18:]
rawPath = "https://raw.githubusercontent.com" + rawPath
rawPath = strings.Replace(rawPath, "/blob/", "/", 1)
matcher = "raw"
}

Expand Down
192 changes: 192 additions & 0 deletions proxy/hotpath_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading