feat: per-link IP whitelist, clipboard fallback, and link editing
Build and Push to GHCR / build-and-push (push) Has been cancelled
Build and Push to GHCR / build-and-push (push) Has been cancelled
- Add `allowed_ips` to Link model with IPv4/IPv6 and CIDR support - Validate client IP in proxy auth middleware against link whitelist - Extract client IP from X-Forwarded-For / X-Real-Ip headers - Fix copy button for non-HTTPS contexts via execCommand fallback - Allow editing existing links (name, type, auth mode, rate limit, IPs) - Add dedicated IP whitelist modal for quick editing via [HAPI](https://hapi.run) Co-Authored-By: HAPI <[email protected]>
This commit is contained in:
@@ -1,11 +1,16 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mirror-proxy/internal/auth"
|
||||
)
|
||||
|
||||
// NewGitHubProxy 创建 GitHub 主站反向代理
|
||||
@@ -18,12 +23,50 @@ func NewGitHubProxy() http.Handler {
|
||||
req.URL.Host = target.Host
|
||||
req.Host = target.Host
|
||||
req.Header.Set("Host", target.Host)
|
||||
// 删除 Accept-Encoding,防止响应被压缩,便于修改 HTML
|
||||
req.Header.Del("Accept-Encoding")
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "MirrorProxy/1.0")
|
||||
}
|
||||
req.Header.Del("X-Forwarded-For")
|
||||
}
|
||||
|
||||
p.ModifyResponse = func(resp *http.Response) error {
|
||||
// 从请求上下文中获取 token 前缀
|
||||
tokenPrefix := ""
|
||||
if resp.Request != nil {
|
||||
if prefix, ok := resp.Request.Context().Value(auth.TokenPrefixContextKey).(string); ok {
|
||||
tokenPrefix = prefix
|
||||
}
|
||||
}
|
||||
|
||||
if tokenPrefix == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 重写 Location header
|
||||
if loc := resp.Header.Get("Location"); loc != "" {
|
||||
resp.Header.Set("Location", rewriteURL(loc, tokenPrefix))
|
||||
}
|
||||
|
||||
// 对 HTML 响应注入 JS 脚本,拦截链接点击
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if strings.Contains(contentType, "text/html") && resp.Body != nil {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
body = injectTokenPrefixScript(body, tokenPrefix)
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
resp.ContentLength = int64(len(body))
|
||||
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(body)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
p.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
@@ -40,6 +83,69 @@ func NewGitHubProxy() http.Handler {
|
||||
return p
|
||||
}
|
||||
|
||||
// rewriteURL 重写 URL,在前面加上 token 前缀
|
||||
func rewriteURL(u string, prefix string) string {
|
||||
if u == "" || prefix == "" {
|
||||
return u
|
||||
}
|
||||
// 已经是完整 URL(带 scheme)
|
||||
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") {
|
||||
return u
|
||||
}
|
||||
// 已经是带前缀的路径
|
||||
if strings.HasPrefix(u, prefix+"/") || u == prefix {
|
||||
return u
|
||||
}
|
||||
// 以 / 开头的绝对路径,加上前缀
|
||||
if strings.HasPrefix(u, "/") {
|
||||
return prefix + u
|
||||
}
|
||||
// 相对路径,不做处理
|
||||
return u
|
||||
}
|
||||
|
||||
// injectTokenPrefixScript 在 HTML 中注入 JS 脚本,拦截链接点击自动补全 token 前缀
|
||||
func injectTokenPrefixScript(body []byte, prefix string) []byte {
|
||||
script := []byte(`<script>` +
|
||||
`(function(){` +
|
||||
`var p='` + prefix + `';` +
|
||||
`document.addEventListener('click',function(e){` +
|
||||
`var a=e.target.closest('a');` +
|
||||
`if(!a)return;` +
|
||||
`var h=a.getAttribute('href');` +
|
||||
`if(h&&h.startsWith('/')&&!h.startsWith(p+'/')&&h!==p){` +
|
||||
`e.preventDefault();` +
|
||||
`location.href=p+h;` +
|
||||
`}` +
|
||||
`},true);` +
|
||||
`document.addEventListener('submit',function(e){` +
|
||||
`var f=e.target.closest('form');` +
|
||||
`if(!f)return;` +
|
||||
`var h=f.getAttribute('action');` +
|
||||
`if(h&&h.startsWith('/')&&!h.startsWith(p+'/')&&h!==p){` +
|
||||
`f.setAttribute('action',p+h);` +
|
||||
`}` +
|
||||
`},true);` +
|
||||
`})();` +
|
||||
`</script>`)
|
||||
|
||||
// 尝试在 </head> 前插入
|
||||
if idx := bytes.Index(body, []byte("</head>")); idx != -1 {
|
||||
return append(body[:idx], append(script, body[idx:]...)...)
|
||||
}
|
||||
// 或者在 <body> 标签后插入
|
||||
if idx := bytes.Index(body, []byte("<body")); idx != -1 {
|
||||
// 找到 <body> 标签的结束位置
|
||||
endIdx := bytes.Index(body[idx:], []byte(">"))
|
||||
if endIdx != -1 {
|
||||
pos := idx + endIdx + 1
|
||||
return append(body[:pos], append(script, body[pos:]...)...)
|
||||
}
|
||||
}
|
||||
// fallback:在文档开头插入
|
||||
return append(script, body...)
|
||||
}
|
||||
|
||||
// NewGitHubRawProxy 创建 GitHub Raw 反向代理
|
||||
func NewGitHubRawProxy() http.Handler {
|
||||
target, _ := url.Parse("https://raw.githubusercontent.com")
|
||||
@@ -56,6 +162,21 @@ func NewGitHubRawProxy() http.Handler {
|
||||
req.Header.Del("X-Forwarded-For")
|
||||
}
|
||||
|
||||
p.ModifyResponse = func(resp *http.Response) error {
|
||||
tokenPrefix := ""
|
||||
if resp.Request != nil {
|
||||
if prefix, ok := resp.Request.Context().Value(auth.TokenPrefixContextKey).(string); ok {
|
||||
tokenPrefix = prefix
|
||||
}
|
||||
}
|
||||
if tokenPrefix != "" {
|
||||
if loc := resp.Header.Get("Location"); loc != "" {
|
||||
resp.Header.Set("Location", rewriteURL(loc, tokenPrefix))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
p.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
@@ -88,6 +209,21 @@ func NewGitHubAPIProxy() http.Handler {
|
||||
req.Header.Del("X-Forwarded-For")
|
||||
}
|
||||
|
||||
p.ModifyResponse = func(resp *http.Response) error {
|
||||
tokenPrefix := ""
|
||||
if resp.Request != nil {
|
||||
if prefix, ok := resp.Request.Context().Value(auth.TokenPrefixContextKey).(string); ok {
|
||||
tokenPrefix = prefix
|
||||
}
|
||||
}
|
||||
if tokenPrefix != "" {
|
||||
if loc := resp.Header.Get("Location"); loc != "" {
|
||||
resp.Header.Set("Location", rewriteURL(loc, tokenPrefix))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
p.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
|
||||
Reference in New Issue
Block a user