Initial commit

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 13:57:10 +08:00
co-authored by Claude Opus 5
commit 8679200f41
1897 changed files with 257900 additions and 0 deletions
+667
View File
@@ -0,0 +1,667 @@
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
}
+58
View File
@@ -0,0 +1,58 @@
package updownctrl
import (
"net/url"
"strings"
"testing"
"time"
"91porn-server/common/hevcpull"
)
func TestSignedTranscodeChildPlaylistURI(t *testing.T) {
now := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC)
expiresAt := now.Add(time.Hour)
secret := "test-pull-secret-strong-32-bytes!!"
childURI, err := signedTranscodeChildPlaylistURI(
"sp/movie/master.m3u8",
"720/index.m3u8",
secret,
expiresAt,
)
if err != nil {
t.Fatalf("signedTranscodeChildPlaylistURI failed: %v", err)
}
if !strings.HasPrefix(childURI, transcodeM3u8RoutePrefix+"sp/movie/720/index.m3u8?") {
t.Fatalf("unexpected child URI: %s", childURI)
}
parsed, err := url.Parse("https://app.example.com" + childURI)
if err != nil {
t.Fatalf("parse child URI: %v", err)
}
if err = hevcpull.VerifyURL(parsed, secret, now); err != nil {
t.Fatalf("child URI signature failed: %v", err)
}
if _, err = signedTranscodeChildPlaylistURI(
"sp/movie/master.m3u8",
"https://external.example.com/index.m3u8",
secret,
expiresAt,
); err == nil {
t.Fatal("absolute child playlist was accepted")
}
}
func TestTranscodePlaybackSourcePreservesExistingM3u8Semantics(t *testing.T) {
for _, source := range []string{
"sp/movie/index.m3u8",
"pms/movie/index.m3u8",
"laosiji/m3m/movie/index.m3u8",
} {
got := transcodePlaybackSource(source)
if got != "/"+source {
t.Fatalf("transcodePlaybackSource(%q) = %q", source, got)
}
}
}