diff --git a/internal/handler/proxy.go b/internal/handler/proxy.go new file mode 100644 index 0000000..36d1abe --- /dev/null +++ b/internal/handler/proxy.go @@ -0,0 +1,329 @@ +package handler + +import ( + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/gin-gonic/gin" +) + +// ── ProxyOptions ──────────────────────────────────────────── + +// ProxyOptions 代理行为配置,便于未来扩展不同资源类型的代理端点 +type ProxyOptions struct { + // 上游请求头配置 + UserAgent string // 自定义 UA(空 = 使用默认 Chrome UA) + Referer string // 自定义 Referer(空 = 不发送 Referer) + Origin string // 自定义 Origin(空 = 不发送) + ExtraHeader map[string]string + + // 上游请求行为 + Timeout time.Duration // 请求超时(0 = 无超时,适用于流式传输) + FollowRedirect bool // 是否跟随重定向(默认 true) + + // 客户端请求头转发(从客户端透传到上游) + ForwardRange bool // 转发 Range 头(视频拖动进度) + ForwardAccept bool // 转发 Accept 头 + + // 响应处理 + Streaming bool // 流式传输(边读边写,适用大文件) + BufferSize int // 流式缓冲区大小(0 = 32KB) + DefaultContentType string // 上游无 Content-Type 时的回退值 + ResponseHeaders map[string]string // 额外响应头(如 Cache-Control) + + // 安全 + MaxResponseBytes int64 // 响应体大小上限(0 = 不限制) +} + +func defaultOptions() ProxyOptions { + return ProxyOptions{ + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/125.0.0.0 Safari/537.36", + FollowRedirect: true, + Streaming: true, + BufferSize: 32 * 1024, + } +} + +// ── ProxyHandler ──────────────────────────────────────────── + +// ProxyHandler 通用代理处理器 +// 路由: +// +// GET /api/proxy/video?url=... 视频流代理(无超时,转发 Range) +// GET /api/proxy/resource?url=... 通用资源代理(有超时,可配置缓存头) +type ProxyHandler struct{} + +// NewProxyHandler 创建代理处理器 +func NewProxyHandler() *ProxyHandler { + return &ProxyHandler{} +} + +// RegisterRoutes 注册路由 +func (h *ProxyHandler) RegisterRoutes(r *gin.RouterGroup) { + r.GET("/proxy/video", h.ProxyVideo) + r.GET("/proxy/resource", h.ProxyResource) +} + +// ── 视频流代理 ────────────────────────────────────────────── + +// ProxyVideo GET /api/proxy/video?url=... +// @Summary 视频代理 +// @Description 服务端代理获取视频流,绕过 CDN Referer/CORS 限制。 +// @Description 前端播放器通过此端点加载视频,上游请求不带 Referer。 +// @Tags proxy +// @Produce octet-stream +// @Param url query string true "视频源 URL(需 URL 编码)" +// @Success 200 {file} binary +// @Failure 400 {object} map[string]interface{} +// @Failure 502 {object} map[string]interface{} +// @Router /proxy/video [get] +func (h *ProxyHandler) ProxyVideo(c *gin.Context) { + opts := defaultOptions() + opts.ForwardRange = true // 支持拖动进度条 + opts.DefaultContentType = "video/mp4" + // Timeout 保持 0 —— 视频播放不设超时 + + h.baseProxy(c, "video", opts) +} + +// ── 通用资源代理 ──────────────────────────────────────────── + +// ProxyResource GET /api/proxy/resource?url=... +// @Summary 资源代理 +// @Description 通用资源代理端点,用于代理图片、音频、文件等。 +// @Description 支持通过查询参数配置行为(referer, origin, timeout 等)。 +// @Tags proxy +// @Produce octet-stream +// @Param url query string true "资源 URL(需 URL 编码)" +// @Param referer query string false "自定义 Referer(空 = 不发送)" +// @Param timeout query int false "超时秒数(0 = 不超时,默认 30)" +// @Param streaming query string false "是否流式传输(true/false,默认 true)" +// @Success 200 {file} binary +// @Failure 400 {object} map[string]interface{} +// @Failure 502 {object} map[string]interface{} +// @Router /proxy/resource [get] +func (h *ProxyHandler) ProxyResource(c *gin.Context) { + opts := defaultOptions() + + // 从查询参数覆盖配置 + if ref := c.Query("referer"); ref != "" { + opts.Referer = ref + } + if c.Query("timeout") != "" { + var sec int + if _, err := fmt.Sscanf(c.Query("timeout"), "%d", &sec); err == nil && sec > 0 { + opts.Timeout = time.Duration(sec) * time.Second + } + } else { + opts.Timeout = 30 * time.Second // 默认 30s + } + opts.Streaming = c.Query("streaming") != "false" + + // 资源代理默认 1h 浏览器缓存 + opts.ResponseHeaders = map[string]string{ + "Cache-Control": "public, max-age=3600", + } + + h.baseProxy(c, "resource", opts) +} + +// ── 核心代理逻辑 ──────────────────────────────────────────── + +// baseProxy 可复用的代理核心,所有代理端点共享。 +// +// 流程: +// 1. 解析 & 验证上游 URL +// 2. 构建上游请求(配置化 Header) +// 3. 执行请求(可选超时、重定向跟随) +// 4. 流式透传响应体到客户端 +// 5. 错误统一返回 JSON +func (h *ProxyHandler) baseProxy(c *gin.Context, proxyType string, opts ProxyOptions) { + rawURL := c.Query("url") + if rawURL == "" { + c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "缺少 url 参数"}) + return + } + + // 解码 URL(支持已编码和未编码输入) + decoded, err := url.QueryUnescape(rawURL) + if err != nil { + decoded = rawURL + } + + // 创建上游请求 + req, err := http.NewRequest("GET", decoded, nil) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "无效的 URL"}) + return + } + + // ── 上游请求头 ────────────────────────────────────── + req.Header.Set("User-Agent", opts.UserAgent) + req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9") + + if opts.Referer != "" { + req.Header.Set("Referer", opts.Referer) + } + // 未设置 Referer = 不发送,模仿地址栏直接访问 + + if opts.Origin != "" { + req.Header.Set("Origin", opts.Origin) + } + + for k, v := range opts.ExtraHeader { + req.Header.Set(k, v) + } + + // ── 客户端请求头转发 ──────────────────────────────── + if opts.ForwardRange { + if rh := c.GetHeader("Range"); rh != "" { + req.Header.Set("Range", rh) + } + } + if opts.ForwardAccept { + if ah := c.GetHeader("Accept"); ah != "" { + req.Header.Set("Accept", ah) + } + } else { + req.Header.Set("Accept", "*/*") + } + + // ── HTTP Client ───────────────────────────────────── + client := &http.Client{ + Timeout: opts.Timeout, + } + if opts.FollowRedirect { + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return nil // 跟随重定向,不携带 Referer + } + } else { + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + } + + // ── 执行请求 ──────────────────────────────────────── + resp, err := client.Do(req) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{ + "code": 1, + "msg": fmt.Sprintf("%s 代理请求失败: %s", proxyType, err.Error()), + "proxy_type": proxyType, + }) + return + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + c.JSON(http.StatusBadGateway, gin.H{ + "code": 1, + "msg": fmt.Sprintf("上游返回 %d", resp.StatusCode), + "upstream_status": resp.StatusCode, + "proxy_type": proxyType, + }) + return + } + + // ── 响应头透传 ────────────────────────────────────── + contentType := resp.Header.Get("Content-Type") + if contentType == "" && opts.DefaultContentType != "" { + contentType = opts.DefaultContentType + } + if contentType != "" { + c.Header("Content-Type", contentType) + } + + copyHeader := func(key string) { + if v := resp.Header.Get(key); v != "" { + c.Header(key, v) + } + } + copyHeader("Content-Length") + copyHeader("Content-Range") + copyHeader("Accept-Ranges") + copyHeader("ETag") + copyHeader("Last-Modified") + copyHeader("Content-Disposition") + + // 确保 Accept-Ranges 存在(视频 seek 需要) + if resp.Header.Get("Accept-Ranges") == "" && opts.ForwardRange { + c.Header("Accept-Ranges", "bytes") + } + + // 额外响应头 + for k, v := range opts.ResponseHeaders { + c.Header(k, v) + } + + c.Status(resp.StatusCode) + + // ── 响应体传输 ────────────────────────────────────── + if !opts.Streaming { + // 非流式:一次性读取全部 + body, err := io.ReadAll(resp.Body) + if err != nil { + return + } + c.Writer.Write(body) + return + } + + // 流式:边读边写 + bufSize := opts.BufferSize + if bufSize <= 0 { + bufSize = 32 * 1024 + } + buf := make([]byte, bufSize) + written := int64(0) + for { + n, readErr := resp.Body.Read(buf) + if n > 0 { + if opts.MaxResponseBytes > 0 && written+int64(n) > opts.MaxResponseBytes { + // 截断(安全上限) + remaining := opts.MaxResponseBytes - written + if remaining > 0 { + c.Writer.Write(buf[:remaining]) + } + return + } + if _, writeErr := c.Writer.Write(buf[:n]); writeErr != nil { + return // 客户端断开 + } + written += int64(n) + // 每 128KB flush 一次 + if written%(int64(bufSize)*4) == 0 { + c.Writer.Flush() + } + } + if readErr == io.EOF { + break + } + if readErr != nil { + return + } + } +} + +// ── 辅助函数 ──────────────────────────────────────────────── + +// ProxyVideoURL 构建视频代理 URL +func ProxyVideoURL(videoURL string) string { + return "/api/proxy/video?url=" + url.QueryEscape(videoURL) +} + +// ProxyResourceURL 构建资源代理 URL +func ProxyResourceURL(resourceURL string, referer string) string { + if referer != "" { + return "/api/proxy/resource?url=" + url.QueryEscape(resourceURL) + + "&referer=" + url.QueryEscape(referer) + } + return "/api/proxy/resource?url=" + url.QueryEscape(resourceURL) +} + +// ProxyURL 构建代理 URL(向后兼容,等同于 ProxyVideoURL) +func ProxyURL(videoURL string) string { + return ProxyVideoURL(videoURL) +} diff --git a/internal/server/server.go b/internal/server/server.go index 113b503..f0fe064 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -85,6 +85,10 @@ func New(cfg *config.Config) (*Server, error) { statsHandler := handler.NewStats(statsEngine) statsHandler.RegisterRoutes(api) + // 视频代理(绕过 CDN Referer 限制) + proxyHandler := handler.NewProxyHandler() + proxyHandler.RegisterRoutes(api) + // Swagger 文档 docs.SwaggerInfo.Host = fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) @@ -134,6 +138,7 @@ func New(cfg *config.Config) (*Server, error) { trimmed := strings.TrimPrefix(path, "/") data, err := readStaticFile(trimmed) if err == nil { + noCache(c) c.Data(http.StatusOK, mimeByExt(trimmed), data) return } diff --git a/web/static/index.html b/web/static/index.html index a218b6a..0683c5f 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -3,9 +3,11 @@
+ +