diff --git a/README.md b/README.md index 3162637..9c6b04c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,110 @@ + # Hugs-Proxy -一个轻量的学术资源加速/反代下载小工具:把 GitHub 的下载链接通过本地 HTTP 服务转发,从而在某些网络环境下提升可用性。 \ No newline at end of file +> 本项目目前支持Github,后面可能会增加HuggingFace...... + +一个**轻量的 GitHub 资源加速/反代下载**小工具:把 GitHub 的下载链接(Release、Archive、Raw、Gist、部分 git/info 相关请求)通过本地 HTTP 服务转发,从而在某些网络环境下提升可用性。 + +本项目默认只监听 `127.0.0.1`,更改为对外监听前请务必阅读“安全提示”。 + +## 特性 + +- 支持代理的链接类型(不匹配会返回 403): + - `github.com///releases/...` + - `github.com///archive/...` + - `github.com///blob/...`(会自动改写为 `.../raw/...` 进行下载) + - `github.com///raw/...` + - `github.com///info/...`、`github.com///git-...` + - `raw.githubusercontent.com///...` + - `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 文件 + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5201e7a --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module gitea.gangary.cn/gary/Hugs-Proxy + +go 1.26.2 diff --git a/main.go b/main.go new file mode 100644 index 0000000..40637d9 --- /dev/null +++ b/main.go @@ -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[^/]+)/(?P[^/]+)/(?:releases|archive)/.*$`) + exp2 = regexp.MustCompile(`^(?:https?://)?github\.com/(?P[^/]+)/(?P[^/]+)/(?:blob|raw)/.*$`) + exp3 = regexp.MustCompile(`^(?:https?://)?github\.com/(?P[^/]+)/(?P[^/]+)/(?:info|git-).*$`) + exp4 = regexp.MustCompile(`^(?:https?://)?raw\.(?:githubusercontent|github)\.com/(?P[^/]+)/(?P[^/]+)/.+?/.+$`) + exp5 = regexp.MustCompile(`^(?:https?://)?gist\.(?:githubusercontent|github)\.com/(?P[^/]+)/.+?/.+$`) + + 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 +} \ No newline at end of file