330 lines
10 KiB
Go
330 lines
10 KiB
Go
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)
|
||
}
|