Files
rootandClaude Opus 5 8679200f41 Initial commit
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:57:10 +08:00

668 lines
24 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 写"降为"每周期每来源一次"。
// ZSetmember=originscore=该来源累计请求次数 —— ZScore 查单个来源,ZRevRangeWithScores 看 Top。
var (
m3u8RefererMu sync.Mutex
m3u8RefererCounts = make(map[string]int64) // key=originvalue=该来源累计请求次数
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
}