From d9908fcb37e9dfdcaaff445ada66d4270f065fc3 Mon Sep 17 00:00:00 2001 From: yuqianhe Date: Wed, 10 Jun 2026 05:12:15 +0000 Subject: [PATCH] Update server, frontend + add proxy handler --- internal/handler/proxy.go | 329 +++++++++++++++++++++++++++++++++ internal/server/server.go | 5 + web/static/index.html | 6 +- web/static/videoapp/app.js | 45 ++++- web/static/videoapp/index.html | 15 +- web/static/videoapp/style.css | 26 +++ 6 files changed, 421 insertions(+), 5 deletions(-) create mode 100644 internal/handler/proxy.go 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 @@ + + api-server - 运维监控面板 - +
@@ -134,6 +136,6 @@
- + diff --git a/web/static/videoapp/app.js b/web/static/videoapp/app.js index 5cb4855..0a717be 100644 --- a/web/static/videoapp/app.js +++ b/web/static/videoapp/app.js @@ -26,6 +26,42 @@ let totalPages = 1; let currentViewkey = null; let extractedVideoURL = null; +// ── 播放模式('proxy' | 'direct')─────────────────────────── +let playMode = localStorage.getItem('playMode') || 'proxy'; + +function initPlayMode() { + const toggle = document.getElementById('modeToggle'); + if (!toggle) return; + if (playMode === 'direct') toggle.classList.add('direct'); + else toggle.classList.remove('direct'); +} + +function togglePlayMode() { + playMode = (playMode === 'proxy') ? 'direct' : 'proxy'; + localStorage.setItem('playMode', playMode); + const toggle = document.getElementById('modeToggle'); + if (playMode === 'direct') { + toggle.classList.add('direct'); + } else { + toggle.classList.remove('direct'); + } + // 如果当前正在播放视频,重新加载 + if (extractedVideoURL && !document.getElementById('videoPlayer').classList.contains('hidden')) { + startVideoPlayer(extractedVideoURL); + } +} + +// 根据模式构建视频加载 URL +function getVideoLoadURL(rawURL) { + if (playMode === 'direct') return rawURL; + return '/api/proxy/video?url=' + encodeURIComponent(rawURL); +} + +// 根据模式返回 referrerpolicy 值 +function getVideoReferrerPolicy() { + return (playMode === 'direct') ? 'no-referrer' : 'no-referrer'; +} + function navigate(hash) { window.location.hash = hash; } @@ -250,6 +286,12 @@ function startVideoPlayer(src) { const source = document.getElementById('videoSource'); const errorOverlay = document.getElementById('playerError'); + // 双模式加载:直连(剥离 Referer)或后端代理 + const loadURL = getVideoLoadURL(src); + + // 设置 Referrer Policy(直连模式关键:阻止浏览器发送 Referer) + video.setAttribute('referrerpolicy', getVideoReferrerPolicy()); + // 先注册事件,再加载 video.onloadedmetadata = function() { document.getElementById('playerStatus').className = 'player-status success'; @@ -285,7 +327,7 @@ function startVideoPlayer(src) { }; // 切换显示:隐藏占位,显示播放器 - source.src = src; + source.src = loadURL; video.load(); playerBox.classList.add('hidden'); video.classList.remove('hidden'); @@ -390,6 +432,7 @@ function formatDuration(seconds) { // ── 事件 ──────────────────────────────────────────────────── window.addEventListener('hashchange', handleRoute); window.addEventListener('load', () => { + initPlayMode(); handleRoute(); refreshNavStats(); setInterval(refreshNavStats, 10000); diff --git a/web/static/videoapp/index.html b/web/static/videoapp/index.html index d46cae3..cfedde8 100644 --- a/web/static/videoapp/index.html +++ b/web/static/videoapp/index.html @@ -3,8 +3,11 @@ + + + Sol148 Video - 视频浏览 - + @@ -98,6 +101,14 @@ +
+ 加载模式 +
+ 🛡️ 代理 + 🔗 直连 + +
+

--

@@ -130,6 +141,6 @@ Data via sol148-extractor API - + diff --git a/web/static/videoapp/style.css b/web/static/videoapp/style.css index dd1584d..ed3fe97 100644 --- a/web/static/videoapp/style.css +++ b/web/static/videoapp/style.css @@ -147,6 +147,32 @@ body { .player-status.success { border-left:3px solid var(--success); color:var(--success); } .player-status.error { border-left:3px solid var(--accent); color:var(--accent); } +/* ── 播放模式切换 ──────────────────────────────────────── */ +.player-mode { + display:flex; align-items:center; gap:10px; margin-top:8px; + padding:8px 12px; background:var(--card); border-radius:8px; font-size:0.8rem; +} +.player-mode .mode-label { color:var(--text2); white-space:nowrap; } +.mode-toggle { + position:relative; display:flex; align-items:center; gap:0; + background:var(--bg); border-radius:20px; padding:3px; + cursor:pointer; user-select:none; + border:1px solid var(--border); +} +.mode-option { + position:relative; z-index:1; padding:5px 14px; border-radius:17px; + color:var(--text2); transition:color .2s; white-space:nowrap; +} +.mode-option.active { color:var(--text); } +.mode-slider { + position:absolute; top:3px; left:3px; width:calc(50% - 3px); height:calc(100% - 6px); + background:var(--accent2); border-radius:17px; + transition:transform .2s ease; z-index:0; +} +.mode-toggle.direct .mode-slider { transform:translateX(100%); } +.mode-toggle.direct .mode-option[data-mode="direct"] { color:var(--text); } +.mode-toggle.direct .mode-option[data-mode="proxy"] { color:var(--text2); } + .video-detail h2 { font-size:1.3rem; margin-bottom:12px; } .video-meta { display:flex; flex-wrap:wrap; gap:16px; margin-bottom:16px; font-size:0.85rem; color:var(--text2); } .video-actions { display:flex; gap:8px; margin-bottom:12px; flex-wrap:wrap; }