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/redis" "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) { handleTicketEnkey(ctx, false) } // NewEnkey 返回短剧未完成水印时使用的新 M3U8 密钥。 func NewEnkey(ctx *gin.Context) { handleTicketEnkey(ctx, true) } func handleTicketEnkey(ctx *gin.Context, useNewKey bool) { 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 != useNewKey { log.Warn("Enkey: ticket invalid", log.Any("ip", ip), log.Any("videoId", videoId)) err = errors.New("ticket check failed") return } var buffer *bytes.Buffer if useNewKey { buffer, err = m3u8.NewEnkey(c) } else { 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) isOverseas = configser.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 := CheckH5M3U8Ticket( mediaID, expireAt, origM3u8Name, ip, ticket, ) // if !ticketOK { // m3u8Name = H5M3U8FallbackPath // log.Warn("【GetM3U8Ex】H5播放票据无效", // log.Any("mediaId", mediaID), // log.Any("expireAt", expireAt), // log.Any("ip", ip), // log.Any("hasTicket", ticket != "")) // } countTowardDailyLimit := ticketOK && ticketInfo.Playable && !ticketInfo.Preview if countTowardDailyLimit && appg.Conf.RunMod == string(consts.RunModeProd) { userId := resolveM3U8RateLimitUserId(c, ticketInfo) vipValid := isM3U8TicketVipValid(ticketInfo) limit := redisconst.M3U8DayRateLimitMax var cnt int64 var countErr error if vipValid { limit = redisconst.M3U8DayRateLimitVipMax cnt, countErr = m3u8DailyMediaCountByUser(userId, mediaID) } else { cnt, countErr = m3u8DailyMediaCountByIP(ip, mediaID) } if countErr != nil { log.Error("【GetM3U8Ex】统计正片获取次数失败", log.Any("ip", ip), log.Any("userId", userId), log.Any("vipValid", vipValid), log.E(countErr)) } else if cnt > limit { // m3u8Name = m3u8RateLimitFallback status = "rate_limited" log.Warn("GetM3U8Ex daily rate limit, use fallback m3u8", log.Any("ip", ip), log.Any("userId", userId), log.Any("vipValid", vipValid), log.Any("limit", limit), 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 := "" if ticketOK { enkeyPath := "/enkey" if ticketInfo.UseNewKey { enkeyPath = "/newenkey" } enkeyUri = fmt.Sprintf("%s?id=%d&t=%d&ticket=%s", enkeyPath, mediaID, expireAt, url.QueryEscape(ticket)) } var buff *bytes.Buffer if ticketOK && ticketInfo.UseNewKey { _, 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 resolveM3U8RateLimitUserId(c *gin.Context, ticketInfo *H5M3U8Ticket) int64 { if ticketInfo != nil && ticketInfo.UserId > 0 { return ticketInfo.UserId } if c == nil { return 0 } return common.GetUserId(c) } // isM3U8TicketVipValid 用票据内签发时写入的会员过期时间判断,播放热路径不查库。 func isM3U8TicketVipValid(ticketInfo *H5M3U8Ticket) bool { return ticketInfo != nil && ticketInfo.UserId > 0 && ticketInfo.VipExpireAt > time.Now().Unix() } func m3u8DailyMediaCountByIP(ip string, mediaID int64) (int64, error) { return m3u8DailyPlayableCount( redisconst.M3U8DayRateLimitIpKey(ip), redisconst.M3U8DailyMediaIpKey(ip, mediaID), ) } func m3u8DailyMediaCountByUser(userId int64, mediaID int64) (int64, error) { return m3u8DailyPlayableCount( redisconst.M3U8DayRateLimitUserKey(userId), redisconst.M3U8DailyMediaUserKey(userId, mediaID), ) } func m3u8DailyPlayableCount(countKey, firstMediaKey string) (int64, error) { expire := redisconst.M3U8DayRateLimitExpire() first, err := redis.Setnx_NewOK(firstMediaKey, 1, expire) if err != nil { return 0, err } 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, userId int64) string { _ = ua if signed := BuildH5M3U8LegacyURL(videoId, urlPath, ip, true, false, userId); signed != "" { return signed } return urlPath } // GetPlayletM3U8AuthParam 为短剧剧集地址增加票据,并指定是否使用原片新密钥。 func GetPlayletM3U8AuthParam(chapterID int64, urlPath, ip string, useNewKey bool, userId int64) string { if signed := BuildH5M3U8PlayletURL(chapterID, urlPath, ip, useNewKey, userId); signed != "" { return signed } return urlPath } 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 serr := m3u8requestlogmod.InsertOneM3U8RequestLog(c, data); serr != 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 }