387 lines
12 KiB
Go
387 lines
12 KiB
Go
package videoser
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"time"
|
|
"videoweb/app/appg"
|
|
"videoweb/app/service/configser"
|
|
"videoweb/pub/common"
|
|
"videoweb/pub/common/enum/consts"
|
|
"videoweb/pub/common/enum/redisconst"
|
|
"videoweb/pub/common/ipaddr"
|
|
"videoweb/pub/common/log"
|
|
"videoweb/pub/common/m3u8"
|
|
"videoweb/pub/common/srvdef"
|
|
"videoweb/pub/models/m3u8requestlogmod"
|
|
"videoweb/pub/models/playletchaptermod"
|
|
"videoweb/pub/models/playletmod"
|
|
"videoweb/pub/redis"
|
|
|
|
"go.mongodb.org/mongo-driver/bson"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
const m3u8RateLimitFallback = H5M3U8FallbackPath
|
|
|
|
type fileHandler func(ctx *gin.Context) (contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string, err error)
|
|
|
|
func Enkey(ctx *gin.Context) {
|
|
fn := func(c *gin.Context) (contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string, err error) {
|
|
idStr := c.Query("id")
|
|
tStr := c.Query("t")
|
|
ticket := c.Query("ticket")
|
|
|
|
if idStr == "" || tStr == "" || ticket == "" {
|
|
err = errors.New("unauthorized")
|
|
return
|
|
}
|
|
videoId, parseErr := strconv.ParseInt(idStr, 10, 64)
|
|
if parseErr != nil {
|
|
err = errors.New("invalid id")
|
|
return
|
|
}
|
|
tVal, parseErr := strconv.ParseInt(tStr, 10, 64)
|
|
if parseErr != nil {
|
|
err = errors.New("invalid t")
|
|
return
|
|
}
|
|
|
|
ip := ipaddr.ResolveClientIP(c)
|
|
ticketInfo, ticketOK := CheckH5M3U8TicketForEnkey(videoId, tVal, ip, ticket)
|
|
if !ticketOK || ticketInfo.UseNewKey {
|
|
log.Warn("Enkey: ticket invalid", log.Any("ip", ip), log.Any("videoId", videoId))
|
|
err = errors.New("ticket check failed")
|
|
return
|
|
}
|
|
if ticketInfo.OfficialPlayletID > 0 && !officialChapterAllowed(c, ticketInfo.OfficialPlayletID, videoId) {
|
|
err = errors.New("本集暂未在官网开放")
|
|
return
|
|
}
|
|
|
|
buffer, err := m3u8.Enkey(c)
|
|
if err != nil {
|
|
return
|
|
}
|
|
contentLength = int64(buffer.Len())
|
|
contentType = "application/octet-stream"
|
|
reader = buffer
|
|
extraHeaders = map[string]string{"Cache-Control": "private, no-store"}
|
|
return
|
|
}
|
|
handlerFile(ctx, fn)
|
|
}
|
|
|
|
func handlerFile(ctx *gin.Context, h fileHandler) {
|
|
contentLength, contentType, reader, extraHeaders, err := h(ctx)
|
|
if err != nil {
|
|
common.ServerJSON(ctx, srvdef.DownloadM3u8File, err)
|
|
return
|
|
}
|
|
ctx.DataFromReader(http.StatusOK, contentLength, contentType, reader, extraHeaders)
|
|
}
|
|
|
|
func GetM3U8Ex(ctx *gin.Context) {
|
|
fn := func(c *gin.Context) (contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string, err error) {
|
|
start := time.Now()
|
|
m3u8Name := ctx.Param("key")
|
|
origM3u8Name := m3u8Name
|
|
status := "success"
|
|
var mediaID int64
|
|
var expireAt int64
|
|
var ticketOK bool
|
|
var isOverseas bool
|
|
var cdnDomain string
|
|
|
|
ip := ipaddr.ResolveClientIP(c)
|
|
detector := appg.DetectorV6
|
|
if ipaddr.IsIPv4(ip) {
|
|
detector = appg.DetectorV4
|
|
}
|
|
if detector != nil {
|
|
isOverseas, _ = detector.IsOverseasIP(ip)
|
|
}
|
|
|
|
defer func() {
|
|
if err != nil && status == "success" {
|
|
status = "error"
|
|
}
|
|
recordM3U8RequestLog(c, m3u8Name, mediaID, expireAt, ticketOK, isOverseas, cdnDomain, status, err, start)
|
|
}()
|
|
|
|
ticket := ctx.Query("ticket")
|
|
mediaID = parseInt64(ctx.Query("id"))
|
|
expireAt = parseInt64(ctx.Query("t"))
|
|
ticketInfo, ticketOK, _ := CheckH5M3U8TicketReason(
|
|
mediaID, expireAt, origM3u8Name, ip, ticket,
|
|
)
|
|
|
|
// if !ticketOK {
|
|
// m3u8Name = H5M3U8FallbackPath
|
|
// log.Warn("【GetM3U8Ex】H5播放票据无效",
|
|
// log.Any("reason", ticketReason),
|
|
// log.Any("mediaId", mediaID),
|
|
// log.Any("expireAt", expireAt),
|
|
// log.Any("ip", ip),
|
|
// log.Any("clientIP", c.ClientIP()),
|
|
// log.Any("remoteIP", c.RemoteIP()),
|
|
// log.Any("xff", c.GetHeader("X-Forwarded-For")),
|
|
// log.Any("cfIP", c.GetHeader("CF-Connecting-IP")),
|
|
// log.Any("path", origM3u8Name),
|
|
// log.Any("hasTicket", ticket != ""))
|
|
// }
|
|
if ticketOK && ticketInfo.OfficialPlayletID > 0 && !officialChapterAllowed(c, ticketInfo.OfficialPlayletID, mediaID) {
|
|
status = "official_closed"
|
|
err = errors.New("本集暂未在官网开放")
|
|
return
|
|
}
|
|
|
|
countTowardDailyLimit := ticketOK && ticketInfo.Playable && !ticketInfo.Preview
|
|
if countTowardDailyLimit && appg.Conf.RunMod == string(consts.RunModeProd) {
|
|
cnt, countErr := m3u8DailyMediaCountByIP(ip, mediaID)
|
|
if countErr != nil {
|
|
log.Error("【GetM3U8Ex】统计IP正片获取次数失败", log.Any("ip", ip), log.E(countErr))
|
|
} else if cnt > configser.GetM3U8DayRateLimitMax() {
|
|
// m3u8Name = m3u8RateLimitFallback
|
|
status = "rate_limited"
|
|
log.Warn("GetM3U8Ex daily rate limit, use fallback m3u8",
|
|
log.Any("ip", ip),
|
|
log.Any("remoteIP", c.RemoteIP()),
|
|
log.Any("clientIP", c.ClientIP()),
|
|
log.Any("xff", c.GetHeader("X-Forwarded-For")),
|
|
log.Any("cnt", cnt),
|
|
log.Any("mediaId", mediaID),
|
|
log.Any("orig", origM3u8Name),
|
|
log.Any("fallback", m3u8Name))
|
|
}
|
|
}
|
|
|
|
var req m3u8.Req
|
|
if err = ctx.BindQuery(&req); err != nil {
|
|
status = "bind_query_failed"
|
|
return
|
|
}
|
|
req.Name = m3u8Name
|
|
cdnDomain = configser.GetRandCDN()
|
|
originDomain := appg.Conf.OrigDomain
|
|
if isOverseas && len(configser.GetConfigInfo().MediaAppUsCdn) > 0 {
|
|
cdnDomain = configser.GetConfigInfo().MediaAppUsCdn
|
|
}
|
|
log.Info("cdn信息", log.Any("ip:", ip), log.Any("isOverseas", isOverseas), log.Any("cdnDomain", cdnDomain))
|
|
|
|
enkeyUri := ""
|
|
playOriginal := ticketOK && ticketInfo.UseNewKey && m3u8Name != H5M3U8FallbackPath
|
|
if ticketOK {
|
|
enkeyPath := "/enkey"
|
|
if playOriginal {
|
|
enkeyPath = "/newenkey"
|
|
}
|
|
enkeyUri = fmt.Sprintf("%s?id=%d&t=%d&ticket=%s", enkeyPath, mediaID, expireAt, url.QueryEscape(ticket))
|
|
}
|
|
|
|
var buff *bytes.Buffer
|
|
if playOriginal {
|
|
_, buff, err = m3u8.TransformExOriginalPath(req.Name, cdnDomain, originDomain, 2, appg.Conf.TsAuthKey, appg.Conf.LsjMediaConf.M3u8Yuan, appg.Conf.CdnAuthKey, enkeyUri)
|
|
} else {
|
|
_, buff, err = m3u8.TransformEx(req.Name, cdnDomain, originDomain, 2, appg.Conf.TsAuthKey, appg.Conf.LsjMediaConf.M3u8Yuan, appg.Conf.CdnAuthKey, enkeyUri)
|
|
}
|
|
if err != nil {
|
|
status = "transform_failed"
|
|
return
|
|
}
|
|
contentType = "application/vnd.apple.mpegurl"
|
|
reader = bytes.NewReader(buff.Bytes())
|
|
contentLength = int64(buff.Len())
|
|
extraHeaders = map[string]string{"Cache-Control": "private, no-store"}
|
|
return
|
|
}
|
|
handlerFile(ctx, fn)
|
|
}
|
|
|
|
func parseInt64(value string) int64 {
|
|
n, _ := strconv.ParseInt(value, 10, 64)
|
|
return n
|
|
}
|
|
|
|
func m3u8DailyMediaCountByIP(ip string, mediaID int64) (int64, error) {
|
|
expire := redisconst.M3U8DayRateLimitExpire()
|
|
first, err := redis.Setnx_NewOK(redisconst.M3U8DailyMediaIpKey(ip, mediaID), 1, expire)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
countKey := redisconst.M3U8DayRateLimitIpKey(ip)
|
|
if first {
|
|
return redis.Incr(countKey, expire)
|
|
}
|
|
value, err := redis.Get(countKey)
|
|
if err != nil || value == nil {
|
|
return 0, err
|
|
}
|
|
return strconv.ParseInt(*value, 10, 64)
|
|
}
|
|
|
|
func GetM3U8AuthParam(videoId int64, urlPath, ip, ua string) string {
|
|
_ = ua
|
|
if signed := BuildH5M3U8LegacyURL(videoId, urlPath, ip, true, false); signed != "" {
|
|
return signed
|
|
}
|
|
return urlPath
|
|
}
|
|
|
|
// GetPlayletM3U8AuthParam 为短剧剧集地址签发票据。useNewKey 表示当前地址是原片。
|
|
func GetPlayletM3U8AuthParam(chapterID int64, urlPath, ip string, useNewKey bool) string {
|
|
if signed := BuildH5M3U8PlayletURL(chapterID, urlPath, ip, useNewKey); signed != "" {
|
|
return signed
|
|
}
|
|
return urlPath
|
|
}
|
|
|
|
// GetM3U8OfficialParam 返回带官网开放范围校验的剧集播放地址
|
|
func GetM3U8OfficialParam(chapterID, playletID int64, urlPath, ip string, useNewKey bool) string {
|
|
return BuildH5M3U8OfficialURL(chapterID, playletID, urlPath, ip, useNewKey)
|
|
}
|
|
|
|
// NewEnkey 返回短剧原片解密密钥。
|
|
func NewEnkey(ctx *gin.Context) {
|
|
fn := func(c *gin.Context) (contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string, err error) {
|
|
idStr := c.Query("id")
|
|
tStr := c.Query("t")
|
|
ticket := c.Query("ticket")
|
|
if idStr == "" || tStr == "" || ticket == "" {
|
|
err = errors.New("unauthorized")
|
|
return
|
|
}
|
|
videoId, parseErr := strconv.ParseInt(idStr, 10, 64)
|
|
if parseErr != nil {
|
|
err = errors.New("invalid id")
|
|
return
|
|
}
|
|
tVal, parseErr := strconv.ParseInt(tStr, 10, 64)
|
|
if parseErr != nil {
|
|
err = errors.New("invalid t")
|
|
return
|
|
}
|
|
ip := ipaddr.ResolveClientIP(c)
|
|
ticketInfo, ticketOK := CheckH5M3U8TicketForEnkey(videoId, tVal, ip, ticket)
|
|
if !ticketOK || !ticketInfo.UseNewKey {
|
|
err = errors.New("ticket check failed")
|
|
return
|
|
}
|
|
if ticketInfo.OfficialPlayletID > 0 && !officialChapterAllowed(c, ticketInfo.OfficialPlayletID, videoId) {
|
|
err = errors.New("本集暂未在官网开放")
|
|
return
|
|
}
|
|
buffer, err := m3u8.NewEnkey(c)
|
|
if err != nil {
|
|
return
|
|
}
|
|
contentLength = int64(buffer.Len())
|
|
contentType = "application/octet-stream"
|
|
reader = buffer
|
|
extraHeaders = map[string]string{"Cache-Control": "private, no-store"}
|
|
return
|
|
}
|
|
handlerFile(ctx, fn)
|
|
}
|
|
|
|
// officialChapterAllowed 每次获取官网播放清单或密钥时重新读取开放范围
|
|
func officialChapterAllowed(ctx context.Context, playletID, chapterID int64) bool {
|
|
if playletID <= 0 || chapterID <= 0 {
|
|
return false
|
|
}
|
|
playlet, err := playletmod.FindOnePlaylet(ctx, bson.M{
|
|
playletmod.FN.Playlet.Id: playletID,
|
|
playletmod.FN.Playlet.Status: playletmod.StatusUp,
|
|
})
|
|
if err != nil || playlet == nil {
|
|
return false
|
|
}
|
|
chapter, err := playletchaptermod.FindOnePlayletChapter(ctx, bson.M{
|
|
playletchaptermod.FN.PlayletChapter.Id: chapterID,
|
|
playletchaptermod.FN.PlayletChapter.PlayletId: playletID,
|
|
})
|
|
if err != nil || chapter == nil {
|
|
return false
|
|
}
|
|
return playlet.IsOfficialEpisodeOpen(chapter.CurrentEpisode)
|
|
}
|
|
|
|
func LsjEnkey(ctx *gin.Context) {
|
|
fn := func(c *gin.Context) (contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string, err error) {
|
|
buffer, err := m3u8.LsjEnkey(c)
|
|
if err != nil {
|
|
return
|
|
}
|
|
contentLength = int64(buffer.Len())
|
|
contentType = "application/octet-stream"
|
|
reader = buffer
|
|
extraHeaders = map[string]string{}
|
|
return
|
|
}
|
|
handlerFile(ctx, fn)
|
|
}
|
|
|
|
func recordM3U8RequestLog(ctx *gin.Context, m3u8Name string, videoId int64, t int64, ticketOK bool, isOverseas bool, cdnDomain string, status string, reqErr error, start time.Time) {
|
|
doc := &m3u8requestlogmod.M3U8RequestLog{
|
|
CreatedAt: time.Now(),
|
|
Host: ctx.Request.Host,
|
|
RequestURI: ctx.Request.RequestURI,
|
|
M3U8Path: m3u8Name,
|
|
RawQuery: ctx.Request.URL.RawQuery,
|
|
Query: cloneURLValues(ctx.Request.URL.Query()),
|
|
Headers: cloneHeader(ctx.Request.Header),
|
|
VideoId: videoId,
|
|
T: t,
|
|
Token: ctx.Query("ticket"),
|
|
ClientIP: ipaddr.ResolveClientIP(ctx),
|
|
RemoteAddr: ctx.Request.RemoteAddr,
|
|
XForwardedFor: ctx.GetHeader("X-Forwarded-For"),
|
|
XRealIP: ctx.GetHeader("X-Real-IP"),
|
|
CFConnectingIP: ctx.GetHeader("CF-Connecting-IP"),
|
|
UserAgent: ctx.Request.UserAgent(),
|
|
Referer: ctx.GetHeader("Referer"),
|
|
IsOverseas: isOverseas,
|
|
CDNDomain: cdnDomain,
|
|
TokenOK: ticketOK,
|
|
PathOK: true,
|
|
Status: status,
|
|
CostMs: time.Since(start).Milliseconds(),
|
|
}
|
|
if reqErr != nil {
|
|
doc.Error = reqErr.Error()
|
|
}
|
|
|
|
go func(data *m3u8requestlogmod.M3U8RequestLog) {
|
|
c, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
if err1 := m3u8requestlogmod.InsertOneM3U8RequestLog(c, data); err1 != nil {
|
|
log.Warn("recordM3U8RequestLog insert failed", log.Any("status", data.Status), log.Any("path", data.M3U8Path))
|
|
}
|
|
}(doc)
|
|
}
|
|
|
|
func cloneHeader(header http.Header) map[string][]string {
|
|
return cloneStringValues(map[string][]string(header))
|
|
}
|
|
|
|
func cloneURLValues(values url.Values) map[string][]string {
|
|
return cloneStringValues(map[string][]string(values))
|
|
}
|
|
|
|
func cloneStringValues(values map[string][]string) map[string][]string {
|
|
result := make(map[string][]string, len(values))
|
|
for key, vals := range values {
|
|
copied := make([]string, len(vals))
|
|
copy(copied, vals)
|
|
result[key] = copied
|
|
}
|
|
return result
|
|
}
|