1 Commits
Author SHA1 Message Date
Geekertao-botandGeekertao-bot d18983e23d feat: 支持代理 GitHub 资源 CDN 直链(release-assets / codeload / objects / media) (#1)
* feat: 支持代理 GitHub 资源 CDN 直链(release-assets / codeload / objects / media)

与 CF 版 hubporg/CF-GitHub-Proxy#3 同一套逻辑:GitHub 域名链接仍在服务端内部
跟随跳转并返回文件内容,新增的 CDN 主机直链则直接代理。

改动要点:
1. exps 拆分为 entryExps(入口链接)与 assetExps(资源 CDN),exps 为二者拼接
2. handler 中 exps[1] 改为显式引用 entryExps[1],避免拼接后索引语义漂移
3. proxy 的 Location 分支改用 isEntryURL:只有入口链接才重写为代理地址,
   CDN 跳转在服务端内部跟随,客户端不会看到真实文件地址
4. 新增 isEntryURL

修复前:资源 CDN 直链(release-assets / codeload 等)返回 404。

* test: 补充 CDN 主机匹配与入口判定的单元测试

覆盖两处关键行为:
- checkURL 命中新增的 CDN 主机(release-assets / objects / github-releases /
  codeload / media),并拒绝非 GitHub 域名与形似域名(如 github.com.evil.com)
- isEntryURL 只命中入口链接,CDN 主机一律不命中——这是避免 302 被重写、
  从而把真实文件地址暴露给客户端的关键约束

(可选提交,如不需要可直接丢弃。)

---------

Co-authored-by: Geekertao-bot <254965144+Geekertao-bot@users.noreply.github.com>
2026-09-12 01:41:54 +08:00
2 changed files with 92 additions and 3 deletions

No files matched your search

+31 -3
View File
@@ -25,7 +25,8 @@ const (
)
var (
exps = []*regexp.Regexp{
// 入口链接:用户可直接粘贴的 GitHub 链接。命中这些的 302 会被重写为代理地址。
entryExps = []*regexp.Regexp{
regexp.MustCompile(`^(?:https?://)?github\.com/([^/]+)/([^/]+)/(?:releases|archive)/.*$`),
regexp.MustCompile(`^(?:https?://)?github\.com/([^/]+)/([^/]+)/(?:blob|raw)/.*$`),
regexp.MustCompile(`^(?:https?://)?github\.com/([^/]+)/([^/]+)/(?:info|git-).*$`),
@@ -33,6 +34,20 @@ var (
regexp.MustCompile(`^(?:https?://)?gist\.github(?:usercontent|)\.com/([^/]+)/.+?/.+$`),
regexp.MustCompile(`^(?:https?://)?api\.github\.com/.+?/([^/]+)(?:/.*)?$`),
}
// 新增:GitHub 资源 CDN 主机(release 资产签名直链 / 归档分流 / LFS 媒体)。
// 这些链接可以直接代理,但**不参与 Location 重写**——保证 github.com 的 302
// 仍在服务端内部跟随,客户端拿到的始终是文件内容而不是真实文件地址。
assetExps = []*regexp.Regexp{
// release-assets:新版 release 资产签名直链(302 目标)
// objects / github-releases:旧版资产 CDN 主机(兼容历史链接)
regexp.MustCompile(`^(?:https?://)?(?:release-assets|objects|github-releases)\.githubusercontent\.com/([^/]+).*$`),
// codeload:源码归档(archive / tar.gz / zip)分流主机
regexp.MustCompile(`^(?:https?://)?codeload\.github\.com/([^/]+)/([^/]+)/.*$`),
// media:Git LFS 媒体文件
regexp.MustCompile(`^(?:https?://)?media\.githubusercontent\.com/(?:media/)?([^/]+)/([^/]+)/.*$`),
}
// checkURL 使用:入口 + 资源 CDN
exps = append(append([]*regexp.Regexp{}, entryExps...), assetExps...)
httpClient *http.Client
config *Config
configLock sync.RWMutex
@@ -196,7 +211,7 @@ func handler(c *gin.Context) {
return
}
if exps[1].MatchString(rawPath) {
if entryExps[1].MatchString(rawPath) {
rawPath = strings.Replace(rawPath, "/blob/", "/raw/", 1)
}
@@ -316,7 +331,9 @@ func proxy(c *gin.Context, u string) {
}
if location := resp.Header.Get("Location"); location != "" {
if checkURL(location) != nil {
// 仅入口链接重写为代理地址;资源 CDN 等跳转目标在服务端内部跟随,
// 直接返回文件内容(客户端看不到真实文件地址)。
if isEntryURL(location) {
c.Header("Location", "/"+location)
} else {
proxy(c, location)
@@ -370,6 +387,17 @@ func checkURL(u string) []string {
return nil
}
// isEntryURL 判断是否为「入口」链接(github.com / raw / gist / api)。
// 用于决定 302 是否重写为代理地址;资源 CDN 链接不在其中。
func isEntryURL(u string) bool {
for _, exp := range entryExps {
if exp.MatchString(u) {
return true
}
}
return false
}
func checkList(matches, list []string) bool {
for _, item := range list {
if strings.HasPrefix(matches[0], item) {
+61
View File
@@ -0,0 +1,61 @@
package main
import "testing"
func TestCheckURL(t *testing.T) {
shouldMatch := []string{
"https://release-assets.githubusercontent.com/github-production-release-asset/1/abc?sig=x",
"https://objects.githubusercontent.com/foo/bar",
"https://github-releases.githubusercontent.com/foo/bar",
"https://codeload.github.com/cli/cli/tar.gz/refs/tags/v1",
"https://media.githubusercontent.com/media/octocat/Hello-World/master/README",
"https://media.githubusercontent.com/octocat/Hello-World/master/README",
"https://github.com/a/b/releases/download/v1/x.zip",
"https://raw.githubusercontent.com/a/b/main/c.txt",
"https://api.github.com/repos/a/b",
}
for _, u := range shouldMatch {
if checkURL(u) == nil {
t.Errorf("checkURL 应命中但未命中: %s", u)
}
}
shouldNotMatch := []string{
"https://evil.example.com/a.zip",
"https://example.com/",
"https://github.com.evil.com/a/b",
"https://notcodeload.github.com/a/b",
}
for _, u := range shouldNotMatch {
if checkURL(u) != nil {
t.Errorf("checkURL 不应命中但命中了: %s", u)
}
}
}
func TestIsEntryURL(t *testing.T) {
// 入口:只有这些才允许重写 Location 返回客户端
entry := []string{
"https://github.com/a/b/releases/download/v1/x.zip",
"https://raw.githubusercontent.com/a/b/main/c.txt",
"https://gist.githubusercontent.com/u/1/raw/x.py",
"https://api.github.com/repos/a/b",
}
for _, u := range entry {
if !isEntryURL(u) {
t.Errorf("isEntryURL 应命中: %s", u)
}
}
// 非入口:资源 CDN 必须在服务端内部跟随,不能重写给客户端
notEntry := []string{
"https://release-assets.githubusercontent.com/github-production-release-asset/1/abc?sig=x",
"https://objects.githubusercontent.com/foo/bar",
"https://github-releases.githubusercontent.com/foo/bar",
"https://codeload.github.com/cli/cli/tar.gz/refs/tags/v1",
"https://media.githubusercontent.com/media/octocat/Hello-World/master/README",
}
for _, u := range notEntry {
if isEntryURL(u) {
t.Errorf("isEntryURL 不应命中(否则会泄露真实文件地址): %s", u)
}
}
}