@@ -1,3 +1,110 @@
|
|||||||
|
|
||||||
# Hugs-Proxy
|
# Hugs-Proxy
|
||||||
|
|
||||||
一个轻量的学术资源加速/反代下载小工具:把 GitHub 的下载链接通过本地 HTTP 服务转发,从而在某些网络环境下提升可用性。
|
> 本项目目前支持Github,后面可能会增加HuggingFace......
|
||||||
|
|
||||||
|
一个**轻量的 GitHub 资源加速/反代下载**小工具:把 GitHub 的下载链接(Release、Archive、Raw、Gist、部分 git/info 相关请求)通过本地 HTTP 服务转发,从而在某些网络环境下提升可用性。
|
||||||
|
|
||||||
|
本项目默认只监听 `127.0.0.1`,更改为对外监听前请务必阅读“安全提示”。
|
||||||
|
|
||||||
|
## 特性
|
||||||
|
|
||||||
|
- 支持代理的链接类型(不匹配会返回 403):
|
||||||
|
- `github.com/<owner>/<repo>/releases/...`
|
||||||
|
- `github.com/<owner>/<repo>/archive/...`
|
||||||
|
- `github.com/<owner>/<repo>/blob/...`(会自动改写为 `.../raw/...` 进行下载)
|
||||||
|
- `github.com/<owner>/<repo>/raw/...`
|
||||||
|
- `github.com/<owner>/<repo>/info/...`、`github.com/<owner>/<repo>/git-...`
|
||||||
|
- `raw.githubusercontent.com/<owner>/<repo>/...`
|
||||||
|
- `gist.github.com/...`、`gist.githubusercontent.com/...`
|
||||||
|
- 白名单/黑名单(先白名单,后黑名单)
|
||||||
|
- 大文件保护:响应体 `Content-Length` 超过 1GB 时直接 302 重定向到源站,避免本机带宽/内存压力
|
||||||
|
- 处理上游重定向:对可识别的 GitHub 下载链接会改写 `Location`,让跳转继续走本代理
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1) 运行
|
||||||
|
|
||||||
|
在仓库根目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run .
|
||||||
|
```
|
||||||
|
|
||||||
|
默认监听:`127.0.0.1:2005`
|
||||||
|
|
||||||
|
### 2) 使用方式
|
||||||
|
|
||||||
|
访问路径就是你要代理的目标 URL(去掉前面的 `/`):
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:2005/<目标URL>
|
||||||
|
```
|
||||||
|
|
||||||
|
目标 URL 可以写全(推荐),也可以省略 scheme(会自动补 `https://`)。示例:
|
||||||
|
|
||||||
|
- Release 文件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:2005/https://github.com/OWNER/REPO/releases/download/v1.0.0/app-darwin-amd64.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
- 仓库归档(archive):
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:2005/https://github.com/OWNER/REPO/archive/refs/heads/main.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
- Raw 文件(也可以给 `blob`,服务端会自动替换为 `raw`):
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:2005/https://github.com/OWNER/REPO/blob/main/README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
- raw.githubusercontent.com:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:2005/https://raw.githubusercontent.com/OWNER/REPO/main/README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你传入的链接不属于上面支持的 GitHub 资源格式,会返回:`403 Invalid input.`
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
目前所有配置都在 [main.go](main.go) 顶部的“配置区域”,修改后重新运行即可:
|
||||||
|
|
||||||
|
- `host`:监听地址(默认 `127.0.0.1`)
|
||||||
|
- `port`:监听端口(默认 `2005`)
|
||||||
|
- `sizeLimit`:大文件阈值(默认 `1GB`)
|
||||||
|
- `whiteListStr`:白名单(多行字符串,每行一条规则)
|
||||||
|
- `blackListStr`:黑名单(多行字符串,每行一条规则)
|
||||||
|
|
||||||
|
### 白名单/黑名单规则
|
||||||
|
|
||||||
|
每行一条,支持三种写法:
|
||||||
|
|
||||||
|
- `user1`:匹配/封禁 `user1` 下的所有仓库
|
||||||
|
- `user1/repo1`:匹配/封禁 `user1/repo1`
|
||||||
|
- `*/repo1`:匹配/封禁所有名为 `repo1` 的仓库
|
||||||
|
|
||||||
|
判定顺序:
|
||||||
|
|
||||||
|
1. **白名单优先生效**:如果白名单非空,则必须至少命中一条白名单规则,否则直接拒绝(403)。
|
||||||
|
2. **再匹配黑名单**:命中任意黑名单规则则拒绝(403)。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 为什么有时会直接跳转到 GitHub?
|
||||||
|
|
||||||
|
当上游响应 `Content-Length` 大于 `sizeLimit`(默认 1GB)时,程序会返回 302 重定向到目标 URL,而不是继续转发大文件内容。
|
||||||
|
|
||||||
|
### 支持 Git Clone / Git LFS 吗?
|
||||||
|
|
||||||
|
本项目主要面向“下载/获取资源”。它只放行并代理部分与 GitHub 资源下载相关的 URL 形态;不保证覆盖完整的 Git 协议或 Git LFS 场景。
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
详见 License 文件
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// 配置区域
|
||||||
|
// ========================
|
||||||
|
const (
|
||||||
|
sizeLimit = int64(1024 * 1024 * 1024 * 1) // 允许的文件大小, 1GB
|
||||||
|
host = "127.0.0.1"
|
||||||
|
port = "2005"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 先生效白名单再匹配黑名单
|
||||||
|
// 每行一个规则,示例:
|
||||||
|
// user1 # 封禁user1的所有仓库
|
||||||
|
// user1/repo1 # 封禁user1的repo1
|
||||||
|
// */repo1 # 封禁所有叫做repo1的仓库
|
||||||
|
var (
|
||||||
|
whiteListStr = ``
|
||||||
|
blackListStr = ``
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// 全局变量与预编译正则
|
||||||
|
// ========================
|
||||||
|
var (
|
||||||
|
whiteList [][]string
|
||||||
|
blackList [][]string
|
||||||
|
|
||||||
|
|
||||||
|
exp1 = regexp.MustCompile(`^(?:https?://)?github\.com/(?P<author>[^/]+)/(?P<repo>[^/]+)/(?:releases|archive)/.*$`)
|
||||||
|
exp2 = regexp.MustCompile(`^(?:https?://)?github\.com/(?P<author>[^/]+)/(?P<repo>[^/]+)/(?:blob|raw)/.*$`)
|
||||||
|
exp3 = regexp.MustCompile(`^(?:https?://)?github\.com/(?P<author>[^/]+)/(?P<repo>[^/]+)/(?:info|git-).*$`)
|
||||||
|
exp4 = regexp.MustCompile(`^(?:https?://)?raw\.(?:githubusercontent|github)\.com/(?P<author>[^/]+)/(?P<repo>[^/]+)/.+?/.+$`)
|
||||||
|
exp5 = regexp.MustCompile(`^(?:https?://)?gist\.(?:githubusercontent|github)\.com/(?P<author>[^/]+)/.+?/.+$`)
|
||||||
|
|
||||||
|
httpClient *http.Client
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// 1. 初始化列表
|
||||||
|
whiteList = parseList(whiteListStr)
|
||||||
|
blackList = parseList(blackListStr)
|
||||||
|
|
||||||
|
httpClient = &http.Client{
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
Timeout: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
http.HandleFunc("/", routeHandler)
|
||||||
|
addr := fmt.Sprintf("%s:%s", host, port)
|
||||||
|
log.Printf("服务器启动成功,正在监听 %s", addr)
|
||||||
|
if err := http.ListenAndServe(addr, nil); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func routeHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
u := strings.TrimPrefix(r.URL.RequestURI(), "/")
|
||||||
|
if strings.HasPrefix(u, "https:/") && !strings.HasPrefix(u, "https://") {
|
||||||
|
u = "https://" + strings.TrimPrefix(u, "https:/")
|
||||||
|
} else if strings.HasPrefix(u, "http:/") && !strings.HasPrefix(u, "http://") {
|
||||||
|
u = "http://" + strings.TrimPrefix(u, "http:/")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(u, "http") {
|
||||||
|
u = "https://" + u
|
||||||
|
}
|
||||||
|
|
||||||
|
m := checkURL(u)
|
||||||
|
if m == nil {
|
||||||
|
http.Error(w, "Invalid input.", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(whiteList) > 0 {
|
||||||
|
allowed := false
|
||||||
|
for _, i := range whiteList {
|
||||||
|
if matchRule(m, i) {
|
||||||
|
allowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
http.Error(w, "Forbidden by white list.", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, i := range blackList {
|
||||||
|
if matchRule(m, i) {
|
||||||
|
http.Error(w, "Forbidden by black list.", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将网页浏览的blob链接统一替换为raw下载链接
|
||||||
|
if exp2.MatchString(u) {
|
||||||
|
u = strings.Replace(u, "/blob/", "/raw/", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发起代理请求
|
||||||
|
parsedURL, err := url.Parse(u)
|
||||||
|
if err == nil {
|
||||||
|
u = parsedURL.String()
|
||||||
|
}
|
||||||
|
proxy(w, r, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
func proxy(w http.ResponseWriter, r *http.Request, targetURL string) {
|
||||||
|
// 修正由于多重代理可能造成的 URL 格式错误
|
||||||
|
if strings.HasPrefix(targetURL, "https:/") && ! strings.HasPrefix(targetURL, "https://") {
|
||||||
|
targetURL = "https://" + targetURL[7:]
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(r.Method, targetURL, r.Body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "server error " + err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拷贝 Header, 但不包含 Host
|
||||||
|
for k, vv := range r.Header {
|
||||||
|
if strings.EqualFold(k, "Host") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, v := range vv {
|
||||||
|
req.Header.Add(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "server error " + err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// 校验大文件限制
|
||||||
|
if resp.ContentLength > sizeLimit {
|
||||||
|
http.Redirect(w, r, targetURL, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理重定向 Location Header
|
||||||
|
if loc := resp.Header.Get("Location"); loc != "" {
|
||||||
|
if checkURL(loc) != nil {
|
||||||
|
resp.Header.Set("Location", "/"+loc)
|
||||||
|
} else {
|
||||||
|
// 如果不符合 Github 下载格式,则递归代理目标地址
|
||||||
|
proxy(w, r, loc)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拷贝响应的 Header 并写入状态码
|
||||||
|
for k, vv := range resp.Header {
|
||||||
|
for _, v := range vv {
|
||||||
|
w.Header().Add(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
|
||||||
|
// Go 会原生以 Stream 方式拷贝数据给 Client 端
|
||||||
|
io.Copy(w, resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchRule(m []string, i []string) bool {
|
||||||
|
// m 通常为 [author, repo] 或 [author]
|
||||||
|
if len(i) == 1 {
|
||||||
|
return len(m) >= 1 && m[0] == i[0]
|
||||||
|
} else if len(i) == 2 {
|
||||||
|
if i[0] == "*" && len(m) >= 2 && m[1] == i[1] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return len(m) >= 2 && m[0] == i[0] && m[1] == i[1]
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkURL(u string) []string {
|
||||||
|
exps := []*regexp.Regexp {exp1, exp2, exp3, exp4, exp5}
|
||||||
|
for _, exp := range exps {
|
||||||
|
matches := exp.FindStringSubmatch(u)
|
||||||
|
if matches != nil {
|
||||||
|
var result []string
|
||||||
|
for i, name := range exp.SubexpNames() {
|
||||||
|
if i > 0 && name != "" {
|
||||||
|
result = append(result, matches[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func parseList(s string) [][]string {
|
||||||
|
var res [][]string
|
||||||
|
lines := strings.Split(s, "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line != "" {
|
||||||
|
parts := strings.Split(line, "/")
|
||||||
|
var cleaned []string
|
||||||
|
for _, p := range parts {
|
||||||
|
cleaned = append(cleaned, strings.ReplaceAll(p, " ", ""))
|
||||||
|
}
|
||||||
|
res = append(res, cleaned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user