668 lines
24 KiB
Go
668 lines
24 KiB
Go
package updownctrl
|
||
|
||
import (
|
||
"encoding/base64"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"91porn-server/app/appg"
|
||
"91porn-server/app/service/m3u8ticket"
|
||
"91porn-server/app/service/updownloadser"
|
||
"91porn-server/common"
|
||
"91porn-server/common/constant/redisconst"
|
||
"91porn-server/common/hevcpull"
|
||
"91porn-server/common/log"
|
||
"91porn-server/common/m3u8"
|
||
"91porn-server/common/stderr"
|
||
"91porn-server/models/commod"
|
||
"91porn-server/models/v/sourcemod"
|
||
"91porn-server/models/v/usermod"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
const (
|
||
Retries = 3 //重试3次
|
||
transcodeM3u8RoutePrefix = "/api/app/vid/transcode/m3u8/"
|
||
transcodeSigningOriginHost = "hevc-pull.invalid"
|
||
// ctxM3u8TicketRequired 标记当前 m3u8 路由需要做 H5 防盗链票据校验。
|
||
// 仅 App H5 播放路由挂载 RequireM3u8Ticket;官网/分享等自有鉴权路由不挂载,避免误伤。
|
||
ctxM3u8TicketRequired = "m3u8_ticket_required"
|
||
)
|
||
|
||
// RequireM3u8Ticket 是一个标记中间件:挂到某条 m3u8 路由后,DownloadM3u8H5 会对其启用票据校验。
|
||
// 未挂载的路由保持旧逻辑(不验票),从而把防盗链范围精确限定在 App H5 播放地址上。
|
||
func RequireM3u8Ticket(c *gin.Context) {
|
||
c.Set(ctxM3u8TicketRequired, true)
|
||
}
|
||
|
||
var interval = []int64{5, 5, 10, 15} //通知时间间隔
|
||
|
||
// Upload doc
|
||
// @Summary 文件管理 - 表单上传文件
|
||
// @Description 表单上传文件
|
||
// @Tags uploaddown
|
||
// @Accept mpfd,json
|
||
// @Produce json,html
|
||
// @Param upload formData file true "文件"
|
||
// @Param id formData string true "文件ID"
|
||
// @Success 200 {string} json "{"msg": "success" "data":{"coverImg":"xxxxxxxxxx.ext"}}"
|
||
// @Failure 400 {string} json "{"msg": "操作失败"}"
|
||
// @Router /vid/upload [post]
|
||
func Upload(c *gin.Context) {
|
||
headers, err := c.FormFile("upload")
|
||
if err != nil {
|
||
log.Warn("request multipart wrong ", log.E(err))
|
||
common.ServeJSON(c, stderr.ErrUploadError, err.Error())
|
||
return
|
||
}
|
||
id := c.PostForm("id")
|
||
if id == "" {
|
||
common.ServeJSON(c, stderr.ErrUploadError, "id is required")
|
||
return
|
||
}
|
||
f, err := headers.Open()
|
||
if err != nil {
|
||
log.Warn("headers Open file wrong ", log.E(err))
|
||
common.ServeJSON(c, stderr.ErrUploadError, err.Error())
|
||
return
|
||
}
|
||
byteData, err := io.ReadAll(f)
|
||
f.Close()
|
||
if err != nil {
|
||
log.Warn("request multipart wrong ", log.E(err))
|
||
common.ServeJSON(c, stderr.ErrUploadError, err.Error())
|
||
return
|
||
}
|
||
fileData := base64.StdEncoding.EncodeToString(byteData)
|
||
ext := strings.TrimLeft(filepath.Ext(headers.Filename), ".")
|
||
resp, err := updownloadser.SendVidCover2FS(id, ext, fileData)
|
||
if err != nil {
|
||
log.Warn("file upload wrong ", log.E(err))
|
||
common.ServeJSON(c, stderr.ErrUploadError, err.Error())
|
||
return
|
||
}
|
||
if resp.Code == http.StatusOK {
|
||
common.ServeJSON(c, stderr.Success, resp.Data)
|
||
return
|
||
}
|
||
common.ServeJSON(c, stderr.ErrUploadError, "")
|
||
}
|
||
|
||
// UploadStatic doc
|
||
// @Summary 文件管理 - 表单上传文件
|
||
// @Description 表单上传文件,上传静态文件到AWS 上传独立文件
|
||
// @Tags uploaddown
|
||
// @Accept mpfd,json
|
||
// @Produce json,html
|
||
// @Param upload formData file true "文件"
|
||
// @Success 200 {string} json "{"msg": "success" "data":{"coverImg":"xxxxxxxxxx.ext"}}"
|
||
// @Failure 400 {string} json "{"msg": "操作失败"}"
|
||
// @Router /vid/uploadStatic [post]
|
||
func UploadStatic(c *gin.Context) {
|
||
headers, err := c.FormFile("upload")
|
||
if err != nil {
|
||
log.Warn("request multipart wrong ", log.E(err))
|
||
common.ServeJSON(c, stderr.ErrUploadError, err.Error())
|
||
return
|
||
}
|
||
f, err := headers.Open()
|
||
if err != nil {
|
||
log.Warn("headers Open file wrong ", log.E(err))
|
||
common.ServeJSON(c, stderr.ErrUploadError, err.Error())
|
||
return
|
||
}
|
||
byteData, err := io.ReadAll(f)
|
||
f.Close()
|
||
if err != nil {
|
||
log.Warn("request multipart wrong ", log.E(err))
|
||
common.ServeJSON(c, stderr.ErrUploadError, err.Error())
|
||
return
|
||
}
|
||
fileData := base64.StdEncoding.EncodeToString(byteData)
|
||
resp, err := updownloadser.SendImageToFS(headers.Filename, fileData)
|
||
if err != nil {
|
||
log.Warn("file upload wrong ", log.E(err))
|
||
common.ServeJSON(c, stderr.ErrUploadError, err.Error())
|
||
return
|
||
}
|
||
if resp.Code == http.StatusOK {
|
||
common.ServeJSON(c, stderr.Success, gin.H{"coverImg": resp.Data.FileName})
|
||
return
|
||
}
|
||
common.ServeJSON(c, stderr.ErrUploadError, "")
|
||
}
|
||
|
||
// UploadDotStream doc
|
||
// @Summary 文件管理 - 流式断点续传文件
|
||
// @Description 流式断点续传
|
||
// @Tags uploaddown
|
||
// @Accept mpfd,json
|
||
// @Produce json,html
|
||
// @Param ID header string true "文件ID 文件MD5做ID"
|
||
// @Param POS header string true "第几片视频"
|
||
// @Param TotalPos header string true "视频总片数"
|
||
// @Success 200 {string} json "{"msg": "success" "data":{"id":"5d8a2af58747044ca077f358","videoUri":"xxxxxxx.m3u8"} }"
|
||
// @Failure 400 {string} json "{"msg": "fail"}"
|
||
// @Router /vid/uploadDotStream [post]
|
||
func UploadDotStream(c *gin.Context) {
|
||
var (
|
||
id, pos, totalPos string
|
||
)
|
||
var cnt int
|
||
var resp commod.Resp
|
||
var httpErr error
|
||
data := c.Request.Body
|
||
id = c.GetHeader("ID")
|
||
pos = c.GetHeader("POS")
|
||
totalPos = c.GetHeader("TotalPos")
|
||
if id == "" || pos == "" || totalPos == "" {
|
||
common.ServeJSON(c, stderr.ErrParamError, "upload args error")
|
||
return
|
||
}
|
||
posint, _ := strconv.ParseInt(pos, 10, 32)
|
||
total, _ := strconv.ParseInt(totalPos, 10, 32)
|
||
byteData, err := io.ReadAll(data)
|
||
data.Close()
|
||
if err != nil {
|
||
log.Warn("request multipart wrong ", log.E(err))
|
||
common.ServeJSON(c, stderr.ErrUploadError, err.Error())
|
||
return
|
||
}
|
||
fileData := base64.StdEncoding.EncodeToString(byteData)
|
||
for cnt < Retries {
|
||
resp, httpErr = updownloadser.SendFile2FS(id, fileData, posint, total)
|
||
if httpErr == nil {
|
||
break
|
||
}
|
||
time.Sleep(time.Duration(interval[cnt]) * time.Second)
|
||
log.Warn("retry to upload file to file-server", log.Any("重试次数", cnt), log.E(httpErr))
|
||
cnt++
|
||
}
|
||
if httpErr != nil {
|
||
log.Warn("file upload wrong ", log.E(err))
|
||
common.ServeJSON(c, stderr.ErrUploadError, nil)
|
||
return
|
||
}
|
||
fmt.Println(resp)
|
||
if resp.Code == http.StatusOK {
|
||
common.ServeJSON(c, stderr.Success, resp.Data)
|
||
return
|
||
}
|
||
common.ServeJSON(c, stderr.ErrUploadError, "")
|
||
}
|
||
|
||
// UploadDotJson doc
|
||
// @Summary 文件管理 - API断点续传文件
|
||
// @Description API断点续传
|
||
// @Tags uploaddown
|
||
// @Accept mpfd,json
|
||
// @Produce json,html
|
||
// @Param id formData string true "taskId 文件MD5做ID"
|
||
// @Param pos formData integer true "第几片视频"
|
||
// @Param totalPos formData integer true "视频总片数"
|
||
// @Param data formData string true "数据内容"
|
||
// @Success 200 {string} json "{"msg": "success" "data":{"id":"5d8a2af58747044ca077f358","videoUri":"xxxxxxx.m3u8"} }"
|
||
// @Failure 400 {string} json "{"msg": "fail"}"
|
||
// @Router /vid/uploadDotJson [post]
|
||
func UploadDotJson(c *gin.Context) {
|
||
uid, err := common.GetUID(c)
|
||
if err == nil && uid > 0 {
|
||
//判断该用户是否被禁止上传视频
|
||
user, err := usermod.FindUserByUID(uid)
|
||
if err != nil || (user != nil && user.ForbidUpload) {
|
||
common.ServeJSON(c, stderr.ForbidUploadVideo, "")
|
||
return
|
||
}
|
||
}
|
||
var cnt int
|
||
var resp commod.Resp
|
||
var httpErr error
|
||
var args struct {
|
||
ID string `form:"id" json:"id" binding:"required"` //taskId
|
||
POS int64 `form:"pos" json:"pos" binding:"required"` //分片序号
|
||
TotalPos int64 `form:"totalPos" json:"totalPos" binding:"required"` //总分片数
|
||
Data string `form:"data" json:"data" binding:"required"` //分片内容
|
||
}
|
||
if err = c.ShouldBind(&args); err != nil {
|
||
common.ServeJSON(c, stderr.ErrParamError, err)
|
||
return
|
||
}
|
||
for cnt < Retries {
|
||
resp, httpErr = updownloadser.SendFile2FS(args.ID, args.Data, args.POS, args.TotalPos)
|
||
if httpErr == nil {
|
||
break
|
||
}
|
||
time.Sleep(time.Duration(interval[cnt]) * time.Second)
|
||
log.Warn("retry to upload file to file-server", log.Any("重试次数", cnt), log.E(httpErr))
|
||
cnt++
|
||
}
|
||
if httpErr != nil {
|
||
log.Warn("file upload wrong ", log.E(err))
|
||
common.ServeJSON(c, stderr.ErrUploadError, nil)
|
||
return
|
||
}
|
||
if resp.Code == http.StatusOK {
|
||
common.ServeJSON(c, stderr.Success, resp.Data)
|
||
return
|
||
}
|
||
common.ServeJSON(c, stderr.ErrUploadError, nil)
|
||
}
|
||
|
||
// Download doc
|
||
// @Summary 文件管理 - 下载文件接口
|
||
// @Description 下载文件
|
||
// @Tags 正式
|
||
// @Accept mpfd,json
|
||
// @Produce json,html
|
||
// @Success 200 {string} json "{"msg": "操作成功"}"
|
||
// @Failure 400 {string} json "{"msg": "操作失败"}"
|
||
// @Router /vid/m3u8/:source [get]
|
||
func DownloadM3u8(c *gin.Context) {
|
||
source := c.Param("source")
|
||
if source == "" {
|
||
common.ServeJSON(c, stderr.ErrParamError, "")
|
||
return
|
||
}
|
||
//cdn有值,则代表前端选线使用
|
||
cdn := c.Query("c")
|
||
// 该接口不做严格校验:老明文链接原样、新带票链接解密还原真实 path,都能播放。
|
||
source = m3u8ticket.StripTicket(source)
|
||
ext := filepath.Ext(source)
|
||
if ext != ".m3u8" {
|
||
common.ServeJSON(c, stderr.ErrMimeType, "")
|
||
return
|
||
}
|
||
fileName := filepath.Base(source)
|
||
|
||
c.Writer.Header().Add("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
|
||
cdns := sourcemod.GetCdnURL()
|
||
if len(cdns) <= 0 {
|
||
common.ServeJSON(c, stderr.Failure, "")
|
||
return
|
||
}
|
||
if cdn == "" {
|
||
//cdn为空时,则前端为老版本,没有选线
|
||
//切记,后台配置第一个域名为当前系统常用cdn域名(eg:松鼠云)
|
||
cdn = cdns[0].Url
|
||
//去掉首尾反斜杠(/)、空格
|
||
cdn = strings.Trim(cdn, "/ ")
|
||
}
|
||
byteBuff, err := m3u8.GetAPPM3u8(source, fileName, ext, cdn, updownloadser.FsIO)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"data": "", "msg": err.Error()})
|
||
return
|
||
}
|
||
if byteBuff == nil {
|
||
log.Warn("can't create m3u8 file", log.Any("source", source))
|
||
c.JSON(http.StatusBadRequest, gin.H{"data": "", "msg": stderr.CodeEmptyData.Msg()})
|
||
return
|
||
}
|
||
c.Writer.Header().Add("Content-Length", strconv.Itoa(byteBuff.Len()))
|
||
c.Data(200, "application/octet-stream", byteBuff.Bytes())
|
||
}
|
||
|
||
// UploadStaticBatch doc
|
||
// @Summary 文件管理 - 表单上传文件 批量上传
|
||
// @Description 表单上传文件,上传静态文件到文件服务器 用于独立文件上传
|
||
// @Tags uploaddown
|
||
// @Accept mpfd,json
|
||
// @Produce json,html
|
||
// @Param upload[] formData file true "文件"
|
||
// @Success 200 {string} json "{"msg": "success" "data":{"coverImg":"xxxxxxxxxx.ext"}}"
|
||
// @Failure 400 {string} json "{"msg": "操作失败"}"
|
||
// @Router /vid/uploadStatic/batch [post]
|
||
func UploadStaticBatch(c *gin.Context) {
|
||
form, err := c.MultipartForm()
|
||
if err != nil {
|
||
log.Warn("request multipart wrong ", log.E(err))
|
||
common.ServeJSON(c, stderr.ErrUploadError, err.Error())
|
||
return
|
||
}
|
||
files := form.File["upload[]"]
|
||
batch := make([]*updownloadser.FileInfo, len(files))
|
||
for i, f := range files {
|
||
fi, err := f.Open()
|
||
if err != nil {
|
||
log.Warn("headers Open file wrong ", log.E(err))
|
||
common.ServeJSON(c, stderr.ErrUploadError, err.Error())
|
||
return
|
||
}
|
||
byteData, err := io.ReadAll(fi)
|
||
fi.Close()
|
||
if err != nil {
|
||
log.Warn("request multipart wrong ", log.E(err))
|
||
common.ServeJSON(c, stderr.ErrUploadError, err.Error())
|
||
return
|
||
}
|
||
fileData := base64.StdEncoding.EncodeToString(byteData)
|
||
batch[i] = &updownloadser.FileInfo{
|
||
FileName: &f.Filename,
|
||
FileData: &fileData,
|
||
}
|
||
}
|
||
resp, err := updownloadser.SendImageToFSBatch(updownloadser.InfoBatch{Batch: batch})
|
||
if err != nil {
|
||
log.Warn("file upload wrong ", log.E(err))
|
||
common.ServeJSON(c, stderr.ErrUploadError, err.Error())
|
||
return
|
||
}
|
||
common.ServeJSON(c, stderr.Success, gin.H{"filePath": resp.Data.GetFileNames(), "success": resp.Data.Count()})
|
||
}
|
||
|
||
// DownloadM3u8H5 doc
|
||
// @Summary 文件管理 - 下载文件接口H5
|
||
// @Description 下载文件
|
||
// @Tags 正式
|
||
// @Accept mpfd,json
|
||
// @Produce json,html
|
||
// @Success 200 {string} json "{"msg": "操作成功"}"
|
||
// @Failure 400 {string} json "{"msg": "操作失败"}"
|
||
// @Router /vid//h5/m3u8/:source [get]
|
||
func DownloadM3u8H5(c *gin.Context) {
|
||
// 统计请求来源(Referer/Origin):进程内无锁累加,后台定时批量刷回 Redis ZSet 计数。
|
||
collectM3u8H5Referer(c)
|
||
source := c.Param("source")
|
||
if source == "" {
|
||
common.ServeJSON(c, stderr.ErrParamError, "")
|
||
return
|
||
}
|
||
//cdn有值,则代表前端选线使用
|
||
cdn := c.Query("c")
|
||
// 防盗链:开启票据后校验,校验失败改下发广告兜底 m3u8,阻断盗链。
|
||
// 带票地址是加密单段 token(无 .m3u8 后缀),故先验票解出真实 path,再判断后缀与取文件名。
|
||
source = verifyH5M3u8Ticket(c, source)
|
||
ext := filepath.Ext(source)
|
||
if ext != ".m3u8" {
|
||
common.ServeJSON(c, stderr.ErrMimeType, "")
|
||
return
|
||
}
|
||
fileName := filepath.Base(source)
|
||
|
||
c.Writer.Header().Add("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
|
||
cdns := sourcemod.GetCdnURL()
|
||
if len(cdns) <= 0 {
|
||
common.ServeJSON(c, stderr.Failure, "")
|
||
return
|
||
}
|
||
if cdn == "" {
|
||
//cdn为空时,则前端为老版本,没有选线
|
||
//切记,后台配置第一个域名为当前系统常用cdn域名(eg:松鼠云)
|
||
cdn = cdns[0].Url
|
||
//去掉首尾反斜杠(/)、空格
|
||
cdn = strings.Trim(cdn, "/ ")
|
||
}
|
||
byteBuff, err := m3u8.GetAPPM3u8(source, fileName, ext, cdn, updownloadser.FsIO)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"data": "", "msg": err.Error()})
|
||
return
|
||
}
|
||
if byteBuff == nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"data": "", "msg": stderr.CodeEmptyData.Msg()})
|
||
return
|
||
}
|
||
c.Writer.Header().Add("Content-Length", strconv.Itoa(byteBuff.Len()))
|
||
c.Data(200, "application/octet-stream", byteBuff.Bytes())
|
||
}
|
||
|
||
// m3u8 请求来源计数(按 origin):进程内用 map[origin]次数 聚合,再由后台定时批量刷回 Redis ZSet。
|
||
// 每个请求都计入(不去重,去重会丢失次数),把高频接口"每请求一次 Redis 写"降为"每周期每来源一次"。
|
||
// ZSet:member=origin,score=该来源累计请求次数 —— ZScore 查单个来源,ZRevRangeWithScores 看 Top。
|
||
var (
|
||
m3u8RefererMu sync.Mutex
|
||
m3u8RefererCounts = make(map[string]int64) // key=origin,value=该来源累计请求次数
|
||
m3u8RefererFlushOnce sync.Once
|
||
)
|
||
|
||
// m3u8RefererFlushInterval 为聚合计数刷回 Redis 的周期;越短则崩溃丢失窗口越小、Redis 写越频繁。
|
||
const m3u8RefererFlushInterval = 5 * time.Second
|
||
|
||
// m3u8RefererResetHour 为来源累计计数每日清零的整点(time.Local,已在启动时设为 Asia/Shanghai):
|
||
// 每天该点该 ZSet 过期失效、从零重新累计。改此值即可调整清零时刻。
|
||
const m3u8RefererResetHour = 5
|
||
|
||
// collectM3u8H5Referer 给当前请求来源(origin)的计数 +1:每个 origin 在 map 里各占一个计数。
|
||
// 加锁只护一次 map 自增,不碰 Redis;计数由后台定时批量刷回 Redis(见 startM3u8RefererFlusher)。
|
||
func collectM3u8H5Referer(c *gin.Context) {
|
||
origin := refererOrigin(c)
|
||
if origin == "" {
|
||
return
|
||
}
|
||
m3u8RefererFlushOnce.Do(startM3u8RefererFlusher) // 首个请求到来时惰性启动后台刷新协程
|
||
m3u8RefererMu.Lock()
|
||
m3u8RefererCounts[origin]++ // 每个 origin 各自累加
|
||
m3u8RefererMu.Unlock()
|
||
}
|
||
|
||
// startM3u8RefererFlusher 启动后台协程,按固定周期把聚合计数批量刷回 Redis。
|
||
func startM3u8RefererFlusher() {
|
||
common.Go(func() {
|
||
ticker := time.NewTicker(m3u8RefererFlushInterval)
|
||
defer ticker.Stop()
|
||
for range ticker.C {
|
||
FlushM3u8RefererStats()
|
||
}
|
||
})
|
||
}
|
||
|
||
// FlushM3u8RefererStats 换出当前按 origin 聚合的计数,逐个 ZIncrBy 刷回 Redis,供定时器与进程退出兜底调用。
|
||
// 锁内只换出快照(不含 Redis IO),换出后本地表即清空(空闲来源自然淘汰);刷回失败的计数并回本地表、下个周期重试。
|
||
func FlushM3u8RefererStats() {
|
||
if appg.Redis == nil {
|
||
return
|
||
}
|
||
m3u8RefererMu.Lock()
|
||
if len(m3u8RefererCounts) == 0 {
|
||
m3u8RefererMu.Unlock()
|
||
return
|
||
}
|
||
snapshot := m3u8RefererCounts
|
||
m3u8RefererCounts = make(map[string]int64)
|
||
m3u8RefererMu.Unlock()
|
||
|
||
var failed map[string]int64
|
||
for origin, cnt := range snapshot {
|
||
if _, err := appg.Redis.ZIncrBy(redisconst.M3u8H5RefererSet, float64(cnt), origin); err != nil {
|
||
if failed == nil {
|
||
failed = make(map[string]int64)
|
||
}
|
||
failed[origin] += cnt
|
||
}
|
||
}
|
||
// 每天凌晨 m3u8RefererResetHour 点整体清零:每次刷回都把过期续到下一个清零点,到点 Redis 删除该 key,
|
||
// 下次刷回自然重建、从零累计。用 EXPIREAT(绝对时间点)而非相对 TTL,故进程重启/无请求空窗期也照常按点失效。
|
||
_, _ = appg.Redis.ExpireKeAt(redisconst.M3u8H5RefererSet, nextM3u8RefererResetAt())
|
||
if len(failed) > 0 {
|
||
m3u8RefererMu.Lock()
|
||
for origin, cnt := range failed {
|
||
m3u8RefererCounts[origin] += cnt
|
||
}
|
||
m3u8RefererMu.Unlock()
|
||
log.Warn("flush m3u8 referer stats partially failed", log.Any("failedSources", len(failed)))
|
||
}
|
||
}
|
||
|
||
// nextM3u8RefererResetAt 返回下一个每日清零时刻(今天 m3u8RefererResetHour 点未过则用今天,已过则用明天),
|
||
// 供刷回时给累计 ZSet 设 EXPIREAT。基于 time.Now()(time.Local=Asia/Shanghai),即北京时间。
|
||
func nextM3u8RefererResetAt() time.Time {
|
||
now := time.Now()
|
||
reset := time.Date(now.Year(), now.Month(), now.Day(), m3u8RefererResetHour, 0, 0, 0, now.Location())
|
||
if !now.Before(reset) { // 已到/过今天清零点,则顺延到明天
|
||
reset = reset.AddDate(0, 0, 1)
|
||
}
|
||
return reset
|
||
}
|
||
|
||
// refererOrigin 提取请求来源站点并归一到 scheme://host:优先 Referer,缺省或解析失败时回退 Origin。
|
||
// 解析不出 host(非法 URL / Origin 为 "null" 等)返回空串丢弃,避免任意串塞进永不过期的 ZSet 撑爆内存。
|
||
func refererOrigin(c *gin.Context) string {
|
||
if o := normalizeOrigin(c.GetHeader("Referer")); o != "" {
|
||
return o
|
||
}
|
||
return normalizeOrigin(c.GetHeader("Origin"))
|
||
}
|
||
|
||
// normalizeOrigin 把来源头归一到 scheme://host;空串或解析不出 host 一律返回空串。
|
||
func normalizeOrigin(raw string) string {
|
||
if raw == "" {
|
||
return ""
|
||
}
|
||
if u, err := url.Parse(raw); err == nil && u.Host != "" {
|
||
return u.Scheme + "://" + u.Host
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// verifyH5M3u8Ticket 校验 H5 m3u8 播放防盗链票据。
|
||
// 未配置密钥时原样返回 source(保持旧逻辑);开启后票据非法/过期/IP 不符则返回广告兜底 source,
|
||
// 并打上 no-store 避免中间层把兜底 playlist 当正片缓存。
|
||
func verifyH5M3u8Ticket(c *gin.Context, source string) string {
|
||
if !m3u8ticket.Enabled() {
|
||
return source
|
||
}
|
||
// 仅 h5/m3u8 这类显式标记 RequireM3u8Ticket 的路由做严格校验;其余路由(h5 light/官网/分享等)只把
|
||
// 带票地址解密还原成真实 path,不校验,保证新老链接都能播放。
|
||
if !c.GetBool(ctxM3u8TicketRequired) {
|
||
return m3u8ticket.StripTicket(source)
|
||
}
|
||
ip := common.GetIP(c)
|
||
ua := ""
|
||
if u, uaErr := common.GetUA(c); uaErr == nil {
|
||
ua = u.UserAgent
|
||
}
|
||
// 带票地址形如 /{version}/{token}.m3u8,真实 path 加密在 token 里。
|
||
realSource, info, ok := m3u8ticket.VerifyPath(source, ip, ua)
|
||
if ok {
|
||
// 若上游 Auth 已解析出登录用户,则要求与票据签发用户一致,进一步绑定到本人。
|
||
if uid := common.TryGetUID(c); uid > 0 && uid != info.UserID {
|
||
ok = false
|
||
}
|
||
}
|
||
if !ok {
|
||
log.Warn("DownloadM3u8H5 ticket invalid",
|
||
log.Any("source", source),
|
||
log.Any("ip", ip),
|
||
)
|
||
c.Header("Cache-Control", "private, no-store")
|
||
return m3u8ticket.FallbackPath
|
||
}
|
||
return realSource
|
||
}
|
||
|
||
// DownloadTranscodeM3u8 仅供 H.265 云转码服务拉取源播放列表。
|
||
// URL 必须由 SKD 使用共享密钥签名,签名同时绑定资源路径和过期时间。
|
||
func DownloadTranscodeM3u8(c *gin.Context) {
|
||
c.Header("Cache-Control", "no-store")
|
||
secret := ""
|
||
if appg.Conf != nil {
|
||
secret = appg.Conf.Hevc.PullSecret
|
||
}
|
||
if err := hevcpull.VerifyURL(c.Request.URL, secret, time.Now()); err != nil {
|
||
log.Warn("DownloadTranscodeM3u8 rejected",
|
||
log.Any("path", c.Request.URL.Path),
|
||
log.E(err),
|
||
)
|
||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||
"code": http.StatusUnauthorized,
|
||
"msg": "invalid or expired transcode pull signature",
|
||
})
|
||
return
|
||
}
|
||
expiresUnix, err := strconv.ParseInt(c.Query(hevcpull.ExpiresParam), 10, 64)
|
||
if err != nil {
|
||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||
"code": http.StatusUnauthorized,
|
||
"msg": "invalid or expired transcode pull signature",
|
||
})
|
||
return
|
||
}
|
||
expiresAt := time.Unix(expiresUnix, 0).UTC()
|
||
// Downstream playback helpers and generic error logs do not need the
|
||
// bearer query after verification; remove it before any further handling.
|
||
c.Request.URL.RawQuery = ""
|
||
c.Request.RequestURI = c.Request.URL.RequestURI()
|
||
source := strings.TrimLeft(c.Param("source"), "/")
|
||
normalizedSource, err := hevcpull.NormalizeSource(source)
|
||
if err != nil || normalizedSource != source {
|
||
log.Warn("DownloadTranscodeM3u8 rejected non-canonical source",
|
||
log.Any("path", c.Request.URL.Path),
|
||
)
|
||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
||
"code": http.StatusBadRequest,
|
||
"msg": "invalid transcode pull source",
|
||
})
|
||
return
|
||
}
|
||
|
||
fileName := filepath.Base(normalizedSource)
|
||
if filepath.Ext(normalizedSource) != ".m3u8" {
|
||
common.ServeJSON(c, stderr.ErrMimeType, "")
|
||
return
|
||
}
|
||
cdns := sourcemod.GetCdnURL()
|
||
if len(cdns) <= 0 {
|
||
common.ServeJSON(c, stderr.Failure, "")
|
||
return
|
||
}
|
||
cdn := strings.Trim(cdns[0].Url, "/ ")
|
||
byteBuff, err := m3u8.GetAPPM3u8(
|
||
transcodePlaybackSource(normalizedSource),
|
||
fileName,
|
||
".m3u8",
|
||
cdn,
|
||
updownloadser.FsIO,
|
||
)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"data": "", "msg": err.Error()})
|
||
return
|
||
}
|
||
byteBuff, err = m3u8.RewriteMasterPlaylist(byteBuff.Bytes(), func(childURI string) (string, error) {
|
||
return signedTranscodeChildPlaylistURI(normalizedSource, childURI, secret, expiresAt)
|
||
})
|
||
if err != nil {
|
||
log.Warn("DownloadTranscodeM3u8 rewrite master failed",
|
||
log.Any("source", normalizedSource),
|
||
log.E(err),
|
||
)
|
||
c.JSON(http.StatusBadRequest, gin.H{"data": "", "msg": "invalid transcode master playlist"})
|
||
return
|
||
}
|
||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
|
||
c.Header("Content-Length", strconv.Itoa(byteBuff.Len()))
|
||
c.Data(http.StatusOK, "application/octet-stream", byteBuff.Bytes())
|
||
}
|
||
|
||
func transcodePlaybackSource(normalizedSource string) string {
|
||
return "/" + strings.TrimLeft(normalizedSource, "/")
|
||
}
|
||
|
||
func signedTranscodeChildPlaylistURI(parentSource, childURI, secret string, expiresAt time.Time) (string, error) {
|
||
childSource, err := hevcpull.ResolveChildSource(parentSource, childURI)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
unsigned := (&url.URL{
|
||
Scheme: "https",
|
||
Host: transcodeSigningOriginHost,
|
||
Path: transcodeM3u8RoutePrefix + childSource,
|
||
}).String()
|
||
signed, err := hevcpull.SignURL(unsigned, secret, expiresAt)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
parsed, err := url.Parse(signed)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
// Root-relative output keeps the trusted host of the originally signed
|
||
// master URL and cannot be influenced by a forwarded Host header.
|
||
return parsed.RequestURI(), nil
|
||
}
|