Add media proxy feature for resource downloading
- Introduced a new API endpoint `GET /api/v1/video/proxy` to facilitate media resource downloads, allowing users to bypass domain restrictions imposed by WeChat. - Updated configuration to include proxy settings such as `SHORT_VIDEO_PROXY_ENABLED`, `SHORT_VIDEO_PROXY_ALLOWED_DOMAINS`, `SHORT_VIDEO_PROXY_MAX_SIZE_MB`, and `SHORT_VIDEO_PROXY_TIMEOUT_SECONDS`. - Enhanced the `ShortVideoConfig` struct to accommodate new proxy-related fields. - Improved error handling for proxy requests, including checks for allowed domains and file size limits. - Updated documentation to reflect the new proxy functionality and its configuration options, ensuring clarity for users and developers.
This commit is contained in:
@@ -2,8 +2,10 @@ package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -36,6 +38,10 @@ type reportDownloadFailureRequest struct {
|
||||
UserAgent string `json:"userAgent"`
|
||||
}
|
||||
|
||||
type proxyMediaRequest struct {
|
||||
URL string `form:"url" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *VideoHandler) RemoveWatermark(c *gin.Context) {
|
||||
var req removeWatermarkRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -136,3 +142,65 @@ func (h *VideoHandler) ReportDownloadFailure(c *gin.Context) {
|
||||
"reported": true,
|
||||
}))
|
||||
}
|
||||
|
||||
// ProxyMedia 代理媒体资源下载
|
||||
// 该接口用于中转视频/图片等媒体资源,使小程序可以通过当前域名下载,避免微信域名限制
|
||||
func (h *VideoHandler) ProxyMedia(c *gin.Context) {
|
||||
var req proxyMediaRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误,缺少 url 参数"))
|
||||
return
|
||||
}
|
||||
|
||||
targetURL := strings.TrimSpace(req.URL)
|
||||
if targetURL == "" {
|
||||
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "url 参数不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
// 调用 service 层进行代理
|
||||
result, err := h.videoService.ProxyMedia(c.Request.Context(), targetURL)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrProxyDisabled):
|
||||
c.JSON(http.StatusServiceUnavailable, model.Error(http.StatusServiceUnavailable, "代理服务未启用"))
|
||||
return
|
||||
case errors.Is(err, service.ErrProxyInvalidURL):
|
||||
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "无效的代理地址"))
|
||||
return
|
||||
case errors.Is(err, service.ErrProxyDomainBlocked):
|
||||
c.JSON(http.StatusForbidden, model.Error(http.StatusForbidden, "该域名不在允许列表中"))
|
||||
return
|
||||
case errors.Is(err, service.ErrProxyFileTooLarge):
|
||||
c.JSON(http.StatusRequestEntityTooLarge, model.Error(http.StatusRequestEntityTooLarge, "文件过大,超出限制"))
|
||||
return
|
||||
default:
|
||||
var thirdPartyErr *service.ThirdPartyError
|
||||
if errors.As(err, &thirdPartyErr) {
|
||||
c.JSON(http.StatusBadGateway, model.Error(http.StatusBadGateway, "上游服务返回错误"))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "代理请求失败"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 确保关闭响应体
|
||||
defer result.Body.Close()
|
||||
|
||||
// 设置响应头
|
||||
c.Header("Content-Type", result.ContentType)
|
||||
if result.ContentLength > 0 {
|
||||
c.Header("Content-Length", strconv.FormatInt(result.ContentLength, 10))
|
||||
}
|
||||
// 允许跨域(如果需要)
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
// 设置缓存头,减少重复请求
|
||||
c.Header("Cache-Control", "public, max-age=86400")
|
||||
|
||||
// 流式输出响应体
|
||||
c.Status(result.StatusCode)
|
||||
|
||||
// 使用 io.Copy 进行流式传输,避免一次性加载到内存
|
||||
_, _ = io.Copy(c.Writer, result.Body)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ var (
|
||||
ErrURLNotFound = errors.New("no valid url found in content")
|
||||
ErrShortVideoAPIKey = errors.New("short video api key is not configured")
|
||||
ErrDailyQuotaExceeded = errors.New("daily free quota exceeded, please watch an ad to continue")
|
||||
ErrProxyDisabled = errors.New("media proxy is disabled")
|
||||
ErrProxyDomainBlocked = errors.New("domain is not in the allowed list")
|
||||
ErrProxyFileTooLarge = errors.New("file size exceeds the limit")
|
||||
ErrProxyInvalidURL = errors.New("invalid proxy url")
|
||||
)
|
||||
|
||||
type VideoService struct {
|
||||
@@ -292,3 +296,131 @@ func (s *VideoService) ReportDownloadFailure(ctx context.Context, report Downloa
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProxyMediaResult 代理媒体请求的结果
|
||||
type ProxyMediaResult struct {
|
||||
Body io.ReadCloser
|
||||
ContentType string
|
||||
ContentLength int64
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
// ProxyMedia 代理媒体资源下载,用于绕过微信小程序的域名限制
|
||||
// 该方法会验证目标URL是否在允许的域名白名单内,并流式转发响应
|
||||
func (s *VideoService) ProxyMedia(ctx context.Context, targetURL string) (*ProxyMediaResult, error) {
|
||||
// 检查是否启用代理功能
|
||||
if !s.cfg.ProxyEnabled {
|
||||
return nil, ErrProxyDisabled
|
||||
}
|
||||
|
||||
// 验证并解析 URL
|
||||
parsed, err := url.Parse(targetURL)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return nil, ErrProxyInvalidURL
|
||||
}
|
||||
|
||||
// 只允许 http/https 协议
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, ErrProxyInvalidURL
|
||||
}
|
||||
|
||||
// 检查域名白名单(如果配置了的话)
|
||||
if len(s.cfg.ProxyAllowedDomains) > 0 {
|
||||
allowed := false
|
||||
for _, domain := range s.cfg.ProxyAllowedDomains {
|
||||
if strings.HasSuffix(parsed.Host, domain) || parsed.Host == domain {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return nil, ErrProxyDomainBlocked
|
||||
}
|
||||
}
|
||||
|
||||
// 创建带超时的 HTTP 客户端(代理请求可能需要更长时间)
|
||||
proxyTimeout := s.cfg.ProxyTimeout
|
||||
if proxyTimeout <= 0 {
|
||||
proxyTimeout = 60 * time.Second
|
||||
}
|
||||
proxyClient := &http.Client{
|
||||
Timeout: proxyTimeout,
|
||||
}
|
||||
|
||||
// 构建代理请求
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build proxy request: %w", err)
|
||||
}
|
||||
|
||||
// 设置常见的请求头,模拟正常浏览器行为
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
|
||||
req.Header.Set("Referer", fmt.Sprintf("%s://%s/", parsed.Scheme, parsed.Host))
|
||||
|
||||
// 发起请求
|
||||
resp, err := proxyClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("proxy request failed: %w", err)
|
||||
}
|
||||
|
||||
// 检查响应状态
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
|
||||
resp.Body.Close()
|
||||
return nil, &ThirdPartyError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: fmt.Sprintf("upstream returned status %d", resp.StatusCode),
|
||||
}
|
||||
}
|
||||
|
||||
// 检查文件大小限制
|
||||
if s.cfg.ProxyMaxSize > 0 && resp.ContentLength > s.cfg.ProxyMaxSize {
|
||||
resp.Body.Close()
|
||||
return nil, ErrProxyFileTooLarge
|
||||
}
|
||||
|
||||
// 获取 Content-Type
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
return &ProxyMediaResult{
|
||||
Body: resp.Body,
|
||||
ContentType: contentType,
|
||||
ContentLength: resp.ContentLength,
|
||||
StatusCode: resp.StatusCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateProxyURL 验证代理URL是否有效(不实际请求,只做格式和白名单检查)
|
||||
func (s *VideoService) ValidateProxyURL(targetURL string) error {
|
||||
if !s.cfg.ProxyEnabled {
|
||||
return ErrProxyDisabled
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(targetURL)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return ErrProxyInvalidURL
|
||||
}
|
||||
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return ErrProxyInvalidURL
|
||||
}
|
||||
|
||||
if len(s.cfg.ProxyAllowedDomains) > 0 {
|
||||
allowed := false
|
||||
for _, domain := range s.cfg.ProxyAllowedDomains {
|
||||
if strings.HasSuffix(parsed.Host, domain) || parsed.Host == domain {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return ErrProxyDomainBlocked
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -12,4 +12,6 @@ func registerRemoveWatermarkRoutes(api *gin.RouterGroup, protected *gin.RouterGr
|
||||
protected.POST("/video/remove_watermark/unlock", videoHandler.UnlockQuota)
|
||||
// 下载失败上报:供其他服务调用,无需鉴权
|
||||
api.POST("/video/remove_watermark/report_failure", videoHandler.ReportDownloadFailure)
|
||||
// 媒体代理:用于中转视频/图片等资源,绕过微信域名限制,无需鉴权
|
||||
api.GET("/video/proxy", videoHandler.ProxyMedia)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user