package videoser import ( "context" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "net/url" "strconv" "strings" "time" "videoweb/pub/common/crypt" "videoweb/pub/common/enum/redisconst" "videoweb/pub/common/ipaddr" "videoweb/pub/common/log" "videoweb/pub/models/usermod" "videoweb/pub/redis" "github.com/gin-gonic/gin" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo/options" ) const ( h5M3U8TicketTTLSeconds = int64(600) h5M3U8TicketKeyVersion = "v1" h5M3U8TicketBindIP = true h5M3U8RoutePrefix = "/h5/m3u8/" hardcodedH5M3U8TicketKey = "3d058714838683a97c04d7b6ab609fc196f29e55df36197341d87325dc63154a" H5M3U8FallbackPath = "/v3/av/w0/w6/db/7j/029e2f2df53d4741ad39b471e2ad1806.m3u8" ) // H5M3U8Ticket 记录业务接口已确认的正片/预览权限,供 m3u8 接口可信计数。 type H5M3U8Ticket struct { MediaID int64 `json:"mediaId"` Path string `json:"path"` ExpireAt int64 `json:"expireAt"` IPHash string `json:"ipHash,omitempty"` UserId int64 `json:"userId,omitempty"` // 签发时登录用户,供会员日限识别 VipExpireAt int64 `json:"vipExpireAt,omitempty"` // 签发时会员过期时间(unix),播放侧据此判断会员日限,避免查库 Playable bool `json:"playable"` Preview bool `json:"preview"` UseNewKey bool `json:"useNewKey,omitempty"` KeyVersion string `json:"keyVersion,omitempty"` } func h5M3U8TicketKey() string { return hardcodedH5M3U8TicketKey } // BuildH5M3U8LegacyURL 返回不带路由前缀的相对票据地址,兼容 H5 自行拼接 /h5/m3u8/。 func BuildH5M3U8LegacyURL(mediaID int64, path, ip string, playable, preview bool, userId int64) string { return buildH5M3U8LegacyURL(mediaID, path, ip, playable, preview, false, userId) } // BuildH5M3U8PlayletURL 为短剧播放地址生成票据,并在票据中记录原片密钥类型。 func BuildH5M3U8PlayletURL(mediaID int64, path, ip string, useNewKey bool, userId int64) string { return buildH5M3U8LegacyURL(mediaID, path, ip, true, false, useNewKey, userId) } func buildH5M3U8LegacyURL(mediaID int64, path, ip string, playable, preview, useNewKey bool, userId int64) string { key := h5M3U8TicketKey() if key == "" || mediaID <= 0 || strings.TrimSpace(ip) == "" { return "" } normalizedPath, ok := normalizeH5M3U8Path(path) if !ok { return "" } expireAt := time.Now().Add(time.Duration(h5M3U8TicketTTLSeconds) * time.Second).Unix() ticket := H5M3U8Ticket{ MediaID: mediaID, Path: normalizedPath, ExpireAt: expireAt, UserId: userId, VipExpireAt: lookupUserVipExpireUnix(userId), Playable: playable, Preview: preview, UseNewKey: useNewKey, KeyVersion: h5M3U8TicketKeyVersion, } if h5M3U8TicketBindIP { ticket.IPHash = h5M3U8Hash(ip) } encodedTicket, err := encodeH5M3U8Ticket(ticket, key) if err != nil { log.Error("【BuildH5M3U8LegacyURL】生成H5播放票据失败", log.E(err)) return "" } query := url.Values{} query.Set("id", strconv.FormatInt(mediaID, 10)) query.Set("t", strconv.FormatInt(expireAt, 10)) query.Set("ticket", encodedTicket) return normalizedPath + "?" + query.Encode() } // CheckH5M3U8Ticket 校验 IP、视频、路径、有效期,防止伪造正片计数语义。 func CheckH5M3U8Ticket(mediaID, expireAt int64, path, ip, ticket string) (*H5M3U8Ticket, bool) { data, ok := decodeAndValidateH5M3U8Ticket(mediaID, expireAt, ip, ticket) if !ok || data == nil { return nil, false } normalizedPath, pathOK := normalizeH5M3U8Path(path) if !pathOK { return nil, false } if payloadPath, valid := normalizeH5M3U8Path(data.Path); !valid || payloadPath != normalizedPath { return nil, false } return data, true } // CheckH5M3U8TicketForEnkey 校验 /enkey 请求,不校验 m3u8 路径。 func CheckH5M3U8TicketForEnkey(mediaID, expireAt int64, ip, ticket string) (*H5M3U8Ticket, bool) { return decodeAndValidateH5M3U8Ticket(mediaID, expireAt, ip, ticket) } func decodeAndValidateH5M3U8Ticket(mediaID, expireAt int64, ip, ticket string) (*H5M3U8Ticket, bool) { key := h5M3U8TicketKey() if key == "" || mediaID <= 0 || expireAt <= 0 || strings.TrimSpace(ticket) == "" { return nil, false } data, err := decodeH5M3U8Ticket(ticket, key) if err != nil { return nil, false } if data.MediaID != mediaID || data.ExpireAt != expireAt { return nil, false } if data.KeyVersion != "" && data.KeyVersion != h5M3U8TicketKeyVersion { return nil, false } if time.Now().Unix() > data.ExpireAt { return nil, false } if h5M3U8TicketBindIP && data.IPHash != h5M3U8Hash(ip) { return nil, false } return data, true } func H5M3U8RequestIP(ctx context.Context) string { if c, ok := ctx.(*gin.Context); ok { return strings.TrimSpace(ipaddr.ResolveClientIP(c)) } return "" } func normalizeH5M3U8TicketParam(ticket string) string { ticket = strings.TrimSpace(ticket) if idx := strings.Index(ticket, "?"); idx >= 0 { ticket = ticket[:idx] } return ticket } func normalizeH5M3U8Path(path string) (string, bool) { path = strings.TrimSpace(path) if path == "" { return "", false } lowerPath := strings.ToLower(path) if strings.HasPrefix(lowerPath, "http://") || strings.HasPrefix(lowerPath, "https://") { return "", false } if idx := strings.Index(path, "?"); idx >= 0 { path = path[:idx] } path = strings.TrimLeft(path, "/") path = strings.TrimPrefix(path, strings.TrimLeft(h5M3U8RoutePrefix, "/")) lowerPath = strings.ToLower(path) if path == "" || strings.Contains(path, `\`) || strings.Contains(path, "..") || strings.Contains(lowerPath, "%2e") { return "", false } if !strings.HasSuffix(lowerPath, ".m3u8") { return "", false } return path, true } func encodeH5M3U8Ticket(ticket H5M3U8Ticket, key string) (string, error) { data, err := json.Marshal(ticket) if err != nil { return "", err } encrypted, err := crypt.CoreAesEncryptEx(data, 12, key) if err != nil { return "", err } return base64.URLEncoding.EncodeToString(encrypted), nil } func decodeH5M3U8Ticket(ticket, key string) (*H5M3U8Ticket, error) { ticket = normalizeH5M3U8TicketParam(ticket) encrypted, err := base64.URLEncoding.DecodeString(ticket) if err != nil { encrypted, err = base64.RawURLEncoding.DecodeString(ticket) } if err != nil { return nil, err } decrypted, err := safeCoreAesDecryptEx(encrypted, 12, key) if err != nil { return nil, err } var data H5M3U8Ticket if err = json.Unmarshal(decrypted, &data); err != nil { return nil, err } return &data, nil } func safeCoreAesDecryptEx(encrypted []byte, nonceLen int, key string) (decrypted []byte, err error) { defer func() { if recovered := recover(); recovered != nil { err = fmt.Errorf("decrypt h5 m3u8 ticket panic: %v", recovered) } }() return crypt.CoreAesDecryptEx(encrypted, nonceLen, key) } func h5M3U8Hash(value string) string { hash := sha256.Sum256([]byte(strings.TrimSpace(value))) return hex.EncodeToString(hash[:]) } // lookupUserVipExpireUnix 取用户会员过期 unix 时间;优先 Redis 短缓存,未登录返回 0。 // 仅在签发票据时调用,避免 /h5/m3u8 热路径查库。 func lookupUserVipExpireUnix(userId int64) int64 { if userId <= 0 { return 0 } cacheKey := redisconst.M3U8UserVipExpireKey(userId) if cached, err := redis.Get(cacheKey); err == nil && cached != nil { if n, parseErr := strconv.ParseInt(*cached, 10, 64); parseErr == nil { return n } } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() user, serr := usermod.FindOneUser(ctx, bson.M{ usermod.FN.User.Id: userId, }, options.FindOne().SetProjection(bson.D{ {Key: usermod.FN.User.Id, Value: 1}, {Key: usermod.FN.User.CurrentVipExpire, Value: 1}, })) vipExpireAt := int64(0) if serr == nil && user != nil && !user.CurrentVipExpire.IsZero() { vipExpireAt = user.CurrentVipExpire.Unix() } _ = redis.Set(cacheKey, strconv.FormatInt(vipExpireAt, 10), redisconst.M3U8UserVipExpireCache) return vipExpireAt }