Files
hadja_server/app/service/videoser/h5_m3u8_ticket.go
T
rootandClaude Opus 4.7 79ac89b458 Initial commit
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-11-07 10:47:18 +08:00

256 lines
7.9 KiB
Go

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
}