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
+159
View File
@@ -0,0 +1,159 @@
package m3u8ticket
import (
"reflect"
"91porn-server/common"
"91porn-server/models/v/vidmod"
"github.com/gin-gonic/gin"
)
// maxSignDepth 反射兜底遍历响应体的最大深度,防止异常对象图导致过深递归(视频响应本身是浅树)。
const maxSignDepth = 8
// ticketSigner 实现 vidmod.M3u8Signer:持有本次请求的签票上下文(用户、客户端 IP/UA),
// 由各响应体的 SignM3u8 回调,对交出的每个 m3u8 地址字段就地签票。
type ticketSigner struct {
userID uint64
ip string
ua string
}
// SignM3u8URL 对单个 m3u8 地址字段就地签票;空串或签发失败(绝对地址、非 m3u8 等)时保持原值不变。
func (s *ticketSigner) SignM3u8URL(field *string, playable, preview bool) {
if field == nil || *field == "" {
return
}
if signed := BuildSignedURL(s.userID, *field, s.ip, s.ua, playable, preview); signed != "" {
*field = signed
}
}
func requestIP(c *gin.Context) string {
if c == nil {
return ""
}
return common.GetIP(c)
}
func requestUA(c *gin.Context) string {
if c == nil {
return ""
}
u, err := common.GetUA(c)
if err != nil {
return ""
}
return u.UserAgent
}
// Sign 是 SignResponse 的便捷封装:自动从上下文取登录用户ID,控制器只需在返回前加一行
// m3u8ticket.Sign(ctx, data) 即可对响应体里的视频 m3u8 地址按需签票。
func Sign(c *gin.Context, data interface{}) {
if c == nil {
return
}
SignResponse(c, common.TryGetUID(c), data)
}
// SignResponse 是各视频接口的统一入口:对响应体 data 里的视频 m3u8 地址就地签票(不再限 H5)。
//
// data 只要实现 vidmod.M3u8Signable(所有携带视频地址的响应体都已实现)即可零反射签票;另外兼容控制器直接
// 下发的裸切片(recommser.GetAd 等)与 gin.H(RecommendList 等)。少数尚未实现接口的历史/边缘类型走反射兜底,
// 保证不漏签。data 为 nil / userID 为 0 / 未配置密钥(BuildSignedURL 返回空)时零副作用,兼容灰度未配置密钥。
func SignResponse(c *gin.Context, userID uint64, data interface{}) {
if data == nil || userID == 0 {
return
}
// data 可能是服务出错时返回的类型化 nil 指针((*Resp)(nil)):接口本身非 nil,但对值接收者方法会解引用
// nil 而 panic,先挡掉(等价于旧反射对 nil 指针的 IsNil 保护)。
if rv := reflect.ValueOf(data); rv.Kind() == reflect.Ptr && rv.IsNil() {
return
}
signData(&ticketSigner{userID: userID, ip: requestIP(c), ua: requestUA(c)}, data)
}
// signData 把 data 分派给对应签票实现:优先零反射的接口/裸切片/map,其余历史类型走反射兜底。
func signData(s vidmod.M3u8Signer, data interface{}) {
switch d := data.(type) {
case nil:
return
case vidmod.M3u8Signable:
d.SignM3u8(s)
case []*vidmod.VideoInfo:
vidmod.SignM3u8Infos(s, d)
case []*vidmod.VideoInfoResp:
vidmod.SignM3u8Resps(s, d)
case gin.H:
for _, v := range d {
signData(s, v)
}
default:
signByReflect(s, reflect.ValueOf(data), 0)
}
}
// signByReflect 是反射兜底:仅用于尚未实现 vidmod.M3u8Signable 的历史/边缘响应类型,语义与旧实现一致——
// 遍历对象图,命中 *VideoInfo/*VideoInfoResp 即签票。正常高频接口都走上面的接口分支,不会到这里。
// 待剩余类型补齐 SignM3u8 后,可整体删除本函数及 reflect 依赖。
func signByReflect(s vidmod.M3u8Signer, rv reflect.Value, depth int) {
if depth > maxSignDepth || !rv.IsValid() {
return
}
switch rv.Kind() {
case reflect.Ptr:
if rv.IsNil() {
return
}
switch v := rv.Interface().(type) {
case *vidmod.VideoInfo:
v.SignM3u8(s)
return
case *vidmod.VideoInfoResp:
v.SignM3u8(s)
return
}
signByReflect(s, rv.Elem(), depth+1)
case reflect.Interface:
if rv.IsNil() {
return
}
signByReflect(s, rv.Elem(), depth+1)
case reflect.Struct:
if rv.CanAddr() {
switch v := rv.Addr().Interface().(type) {
case *vidmod.VideoInfo:
v.SignM3u8(s)
return
case *vidmod.VideoInfoResp:
v.SignM3u8(s)
return
}
}
t := rv.Type()
for i := 0; i < rv.NumField(); i++ {
if t.Field(i).PkgPath != "" { // 跳过未导出字段,reflect 无法安全访问
continue
}
signByReflect(s, rv.Field(i), depth+1)
}
case reflect.Slice, reflect.Array:
for i := 0; i < rv.Len(); i++ {
signByReflect(s, rv.Index(i), depth+1)
}
case reflect.Map:
for _, k := range rv.MapKeys() {
signByReflect(s, rv.MapIndex(k), depth+1)
}
}
}
// SignURL 供只有单个 m3u8 地址字段的响应(官网视频、媒体内容等)复用。
// 不再限制 H5:已配置密钥时改写,未配置(BuildSignedURL 返回空)则原样返回,兼容灰度未配置密钥的情况。
func SignURL(c *gin.Context, userID uint64, field *string, playable, preview bool) {
if field == nil || *field == "" || userID == 0 {
return
}
(&ticketSigner{userID: userID, ip: requestIP(c), ua: requestUA(c)}).SignM3u8URL(field, playable, preview)
}
+377
View File
@@ -0,0 +1,377 @@
// Package m3u8ticket 实现 H5 m3u8 播放防盗链票据。
//
// 背景:H5 播放地址 /api/app/vid/h5/m3u8/{path} 原先只靠一个可被任意复制的 path 就能换取真实
// m3u8,导致地址一旦泄露即可被第三方站点盗链直接播放。
//
// 方案:服务端在返回视频地址时,把「用户ID + m3u8 相对 path + 过期时间 + 客户端 IP 摘要 + 播放/试看
// 语义」用二进制紧凑打包,用服务端私钥做 AES 加密(crypt.CoreAesEncryptEx),再 URL-safe base64,得到
// 一个不可读、看似随机的单段 token,拼在一个版本前缀之后作为整条播放地址:
//
// {version}/{token}.m3u8
// 例:tk1/AbC-d....m3u8 (真实 path 已加密进 token,地址上不再出现;结尾 .m3u8 仅供前端/CDN 按后缀识别)
//
// 真实 m3u8 相对路径整段收进密文,第三方从地址上看不到任何真实资源路径或 bucket 结构;token 任何一个
// 字节被篡改都会导致解密/校验失败。DownloadM3u8H5 在放行前剥出 token 解密,校验过期时间/IP 摘要/用户,
// 得到密文里的真实 path 再取 m3u8;校验失败则改下发广告 fallback,从而阻断盗链。
//
// 放进 path 段而不是 query:前端本就把 sourceURL 当作相对路径直接拼在 /api/app/vid/h5/m3u8/ 后面,
// 放进 path 段对前端透明,也不与前端自带的 ?token= 冲突。m3u8 内的 TS/密钥地址均为绝对(CDN)或根绝对
// 地址,不受该前缀影响。
//
// 仅 H5 端启用;原生 App 仍走原始地址与既有鉴权。未配置 h5M3u8TicketKey 时整套逻辑关闭(不签不验),
// 便于先发代码再由运维配置私钥开启严格模式。
package m3u8ticket
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"strings"
"time"
"91porn-server/app/appg"
"91porn-server/common/crypt"
"91porn-server/models/commod"
)
const (
// ticketTTL 票据有效期,签发后 6 小时内有效(覆盖列表签发到实际起播的较长间隔)。
ticketTTL = 6 * time.Hour
// ticketVer 载荷版本(占 1 字节),轮换 h5M3u8TicketKey 或调整载荷格式时用于兼容判断。
ticketVer = byte(1)
// aesNonceLen 与 crypt.CoreAesEncryptEx 约定的随机 nonce 长度。
aesNonceLen = 12
// digestLen IP/UA 摘要保留的字节数(HMAC 截断),8 字节足够防复用又显著缩短 token。
digestLen = 8
// pathScheme 是票据的版本标识段(首段),用于把带票地址和裸地址区分开。
// 取一个真实 m3u8 路径不会用到的前缀(真实路径首段为 v1/v2/v3/bktuserup/bktadminup/sp/pms/laosiji 等)。
pathScheme = "tk1"
// bindIP 是否把客户端 IP 摘要绑进票据,开启后可降低票据被异地/第三方站点复用的风险。
bindIP = true
// bindUA 是否把 UA 摘要绑进票据,默认关闭(H5 内嵌播放器与外层 UA 未必一致),预留更严格策略。
bindUA = false
// FallbackPath 校验失败时下发的广告/提示 m3u8(与 limitHandler 限流兜底同一资源)。
FallbackPath = "/bktadminup/sp/zp/hb/7a/0q/860513c4947143b29083160ec41588b5.m3u8"
)
// 载荷标志位(1 字节)。
const (
flagPlayable byte = 1 << iota // 是否真实可播放(正式播放权限)
flagPreview // 是否试看/预览票
flagHasIP // 载荷内是否含 IP 摘要
flagHasUA // 载荷内是否含 UA 摘要
)
var errBadTicket = errors.New("m3u8 ticket malformed")
// Ticket 是解密后的票据载荷(内存态)。序列化用二进制紧凑格式,不导出摘要字段。
type Ticket struct {
UserID uint64 // 播放用户ID,绑定 /vid/info 等鉴权通过的用户
Path string // 允许播放的 m3u8 归一化相对路径(收进密文,不出现在地址上)
ExpireAt int64 // 过期时间戳(秒)
Playable bool // 是否真实可播放
Preview bool // 是否试看/预览票
ipDigest []byte // 客户端 IP 的 HMAC 截断摘要,仅校验用
uaDigest []byte // 客户端 UA 的 HMAC 截断摘要,仅校验用
}
// Enabled 报告防盗链票据是否开启(即是否已配置服务端私钥)。
func Enabled() bool {
return key() != ""
}
func key() string {
if appg.Conf == nil {
return ""
}
return strings.TrimSpace(appg.Conf.Base.H5M3u8TicketKey)
}
// appPrefix 返回带票地址最前面的应用标识前缀 JHA-{appid}/(appid = commod.KFK_APPID,补零至少 3 位,
// 与数据上报口径一致)。例:appid=204 -> "JHA-204/"appid=1 -> "JHA-001/"appid=2001 -> "JHA-2001/"。
func appPrefix() string {
return fmt.Sprintf("JHA-%03d/", commod.KFK_APPID)
}
// stripAppPrefix 剥掉带票地址最前面可能的 JHA-{appid}/ 前缀,兼容带/不带前缀两种形态(前缀仅供 CDN/前端识别)。
func stripAppPrefix(s string) string {
if strings.HasPrefix(s, "JHA-") {
if i := strings.IndexByte(s, '/'); i >= 0 {
return s[i+1:]
}
}
return s
}
// BuildSignedURL 为一条 m3u8 相对 path 签发票据,返回 {version}/{token}.m3u8 形式的相对地址。
//
// 真实 path 整段加密进 token,返回地址上不再出现真实路径。返回值保留调用方传入 rawPath 的前导斜杠形态
// (在版本段之前),这样 H5 端沿用旧逻辑自行拼接 /api/app/vid/h5/m3u8/ 前缀即可。
// 返回空串表示无法签发(未开启、userID 非法、path 非合法 m3u8),调用方应保持原值不变。
func BuildSignedURL(userID uint64, rawPath, ip, ua string, playable, preview bool) string {
k := key()
if k == "" || userID == 0 {
return ""
}
normalizedPath, ok := normalizePath(rawPath)
if !ok {
return ""
}
t := &Ticket{
UserID: userID,
Path: normalizedPath,
ExpireAt: time.Now().Add(ticketTTL).Unix(),
Playable: playable,
Preview: preview,
}
token, err := encodeTicket(t, ip, ua, k)
if err != nil {
return ""
}
// 最前面加 JHA-{appid}/ 应用标识前缀(供 CDN/前端识别);末尾补 .m3u8 后缀让 HLS 播放器/CDN 按后缀识别。
// 前缀与后缀仅用于展示,VerifyPath/StripTicket 会先剥掉再解 token。
signed := appPrefix() + pathScheme + "/" + token + ".m3u8"
if strings.HasPrefix(strings.TrimSpace(rawPath), "/") {
signed = "/" + signed
}
return signed
}
// VerifyPath 校验并剥离带票 m3u8 请求路径。
// 入参 source 为 DownloadM3u8H5 拿到的原始 c.Param("source")(形如 /tk1/{token})。
// 返回密文里解出的真实 source(带前导斜杠,供 GetAPPM3u8 使用)、票据载荷、是否校验通过。
// 未带票据前缀或任一校验失败时返回 ok=false;调用方应改下发广告兜底 m3u8。
func VerifyPath(source, ip, ua string) (realSource string, t *Ticket, ok bool) {
k := key()
if k == "" {
return source, nil, false
}
s := strings.TrimLeft(strings.TrimSpace(source), "/")
s = stripAppPrefix(s) // 先剥掉 JHA-{appid}/ 应用前缀
if !strings.HasPrefix(s, pathScheme+"/") {
return source, nil, false // 非带票地址(裸 path)
}
token := s[len(pathScheme)+1:]
if i := strings.IndexByte(token, '/'); i >= 0 {
token = token[:i] // 防御:token 为单段,只取票据段
}
token = strings.TrimSuffix(token, ".m3u8") // 剥掉展示用后缀,还原纯票据
if token == "" {
return source, nil, false
}
data, err := decodeTicket(token, k)
if err != nil || data.UserID == 0 {
return source, nil, false
}
if time.Now().Unix() > data.ExpireAt {
return source, nil, false
}
//if bindIP && !hmac.Equal(data.ipDigest, digest(k, ip)) {
// return source, nil, false
//}
//if bindUA && !hmac.Equal(data.uaDigest, digest(k, ua)) {
// return source, nil, false
//}
// path 来自密文,天然可信;再归一化一次做纵深防御(理论上签发时已归一化)。
normalizedPath, nok := normalizePath(data.Path)
if !nok {
return source, nil, false
}
return "/" + normalizedPath, data, true
}
// StripTicket 供不做严格校验的播放接口(如 /vid/m3u8)使用:若 source 是带票地址,仅解密取出真实 path
// 返回,不校验过期/IP/用户;否则(裸 path 或无法解密)原样返回。用于兼容老明文链接与新带票链接都能播放。
func StripTicket(source string) string {
k := key()
if k == "" {
return source
}
s := strings.TrimLeft(strings.TrimSpace(source), "/")
s = stripAppPrefix(s) // 先剥掉 JHA-{appid}/ 应用前缀
if !strings.HasPrefix(s, pathScheme+"/") {
return source // 裸 path,原样返回
}
token := s[len(pathScheme)+1:]
if i := strings.IndexByte(token, '/'); i >= 0 {
token = token[:i]
}
token = strings.TrimSuffix(token, ".m3u8")
data, err := decodeTicket(token, k)
if err != nil || data.UserID == 0 {
return source // 解不出就原样,尽量不影响播放
}
normalizedPath, ok := normalizePath(data.Path)
if !ok {
return source
}
return "/" + normalizedPath
}
// NormalizePath 对外暴露的 path 归一化(便于测试与调用方复用)。
func NormalizePath(path string) string {
normalized, _ := normalizePath(path)
return normalized
}
// normalizePath 归一化 m3u8 相对 path:剔除完整 URL、反斜杠、../、%2e、非 .m3u8,统一去掉前导斜杠。
func normalizePath(path string) (string, bool) {
path = strings.TrimSpace(path)
if path == "" {
return "", false
}
lower := strings.ToLower(path)
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
return "", false
}
if idx := strings.IndexByte(path, '?'); idx >= 0 {
path = path[:idx]
}
path = strings.TrimLeft(path, "/")
lower = strings.ToLower(path)
if path == "" || strings.Contains(path, `\`) || strings.Contains(path, "..") || strings.Contains(lower, "%2e") {
return "", false
}
if !strings.HasSuffix(lower, ".m3u8") {
return "", false
}
return path, true
}
// encodeTicket 把载荷二进制紧凑打包 -> AES 加密 -> URL-safe base64,得到单段 token。
// 二进制布局:ver(1) flags(1) userID(8,BE) expireAt(4,BE) [ipDigest(8)] [uaDigest(8)] pathLen(2,BE) path(N)。
func encodeTicket(t *Ticket, ip, ua, k string) (string, error) {
pathBytes := []byte(t.Path)
if len(pathBytes) > 0xffff {
return "", errors.New("m3u8 ticket path too long")
}
var flags byte
if t.Playable {
flags |= flagPlayable
}
if t.Preview {
flags |= flagPreview
}
if bindIP {
flags |= flagHasIP
}
if bindUA {
flags |= flagHasUA
}
buf := make([]byte, 0, 16+digestLen*2+len(pathBytes))
buf = append(buf, ticketVer, flags)
buf = binary.BigEndian.AppendUint64(buf, t.UserID)
buf = binary.BigEndian.AppendUint32(buf, uint32(t.ExpireAt))
if flags&flagHasIP != 0 {
buf = append(buf, digest(k, ip)...)
}
if flags&flagHasUA != 0 {
buf = append(buf, digest(k, ua)...)
}
buf = binary.BigEndian.AppendUint16(buf, uint16(len(pathBytes)))
buf = append(buf, pathBytes...)
encrypted, err := crypt.CoreAesEncryptEx(buf, aesNonceLen, k)
if err != nil {
return "", err
}
// RawURLEncoding:字符集 [A-Za-z0-9_-],无 '/' 无 '=' 填充,可安全作为单个 path 段。
return base64.RawURLEncoding.EncodeToString(encrypted), nil
}
// decodeTicket 解 base64 -> AES 解密 -> 按二进制布局解析。对不可信输入做严格边界检查,非法返回错误。
func decodeTicket(token, k string) (*Ticket, error) {
token = strings.TrimSpace(token)
raw, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {
if raw, err = base64.URLEncoding.DecodeString(token); err != nil {
return nil, err
}
}
b, err := safeDecrypt(raw, k)
if err != nil {
return nil, err
}
// 最小:ver(1)+flags(1)+userID(8)+expireAt(4) = 14
if len(b) < 14 {
return nil, errBadTicket
}
off := 0
ver := b[off]
off++
if ver != ticketVer {
return nil, errBadTicket
}
flags := b[off]
off++
userID := binary.BigEndian.Uint64(b[off : off+8])
off += 8
expireAt := int64(binary.BigEndian.Uint32(b[off : off+4]))
off += 4
t := &Ticket{
UserID: userID,
ExpireAt: expireAt,
Playable: flags&flagPlayable != 0,
Preview: flags&flagPreview != 0,
}
if flags&flagHasIP != 0 {
if len(b) < off+digestLen {
return nil, errBadTicket
}
t.ipDigest = b[off : off+digestLen]
off += digestLen
}
if flags&flagHasUA != 0 {
if len(b) < off+digestLen {
return nil, errBadTicket
}
t.uaDigest = b[off : off+digestLen]
off += digestLen
}
if len(b) < off+2 {
return nil, errBadTicket
}
pathLen := int(binary.BigEndian.Uint16(b[off : off+2]))
off += 2
if pathLen == 0 || len(b) < off+pathLen {
return nil, errBadTicket
}
t.Path = string(b[off : off+pathLen])
return t, nil
}
// safeDecrypt 包住底层解密,避免个别非法票据触发 panic 影响请求进程。
func safeDecrypt(encrypted []byte, k string) (decrypted []byte, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("m3u8 ticket decrypt panic: %v", r)
}
}()
decrypted, err = crypt.CoreAesDecryptEx(encrypted, aesNonceLen, k)
if err != nil {
return nil, err
}
if len(decrypted) == 0 {
return nil, errors.New("m3u8 ticket empty payload")
}
return decrypted, nil
}
// digest 用服务端私钥对 value 做 HMAC-SHA256 并截断到 digestLen 字节。
func digest(k, value string) []byte {
h := hmac.New(sha256.New, []byte(k))
h.Write([]byte(strings.TrimSpace(value)))
return h.Sum(nil)[:digestLen]
}
+279
View File
@@ -0,0 +1,279 @@
package m3u8ticket
import (
"reflect"
"strings"
"testing"
"time"
"91porn-server/app/appg"
"91porn-server/models/v/vidmod"
)
const testKey = "unit-test-h5-m3u8-ticket-key-32byteslong!!"
func setKey(t *testing.T, key string) {
t.Helper()
conf := &appg.GlobalConfig{}
conf.Base.H5M3u8TicketKey = key
appg.Conf = conf
}
// signedWith 直接用给定票据构造带票地址(单段 token),供构造过期等异常场景。
func signedWith(t *testing.T, tk *Ticket, ip, ua string) string {
t.Helper()
token, err := encodeTicket(tk, ip, ua, testKey)
if err != nil {
t.Fatalf("encodeTicket: %v", err)
}
return "/" + pathScheme + "/" + token
}
// tamperToken 翻转带票地址里 token 的末位字符,用于构造被篡改的密文。
func tamperToken(signed string) string {
// 篡改 token 主体(跳过展示用 .m3u8 后缀)的末位字符,模拟密文被改。
s := strings.TrimSuffix(signed, ".m3u8")
if len(s) == 0 {
return signed
}
b := []byte(s)
if b[len(b)-1] == 'A' {
b[len(b)-1] = 'B'
} else {
b[len(b)-1] = 'A'
}
return string(b) + ".m3u8"
}
func TestSignVerifyRoundTrip(t *testing.T) {
setKey(t, testKey)
const (
uid = uint64(10086)
path = "bktuserup/sp/9o/06/g7/fn/894552ffed374e63b1a4d4f0d86e7e8c.m3u8"
ip = "1.2.3.4"
)
signed := BuildSignedURL(uid, path, ip, "ua", true, false)
if signed == "" {
t.Fatal("expected non-empty signed url")
}
if !strings.HasPrefix(signed, "JHA-") || !strings.Contains(signed, "/"+pathScheme+"/") {
t.Fatalf("signed url should be JHA-<appid>/tk1/..., got %q", signed)
}
if !strings.HasSuffix(signed, ".m3u8") {
t.Fatalf("signed url should end with .m3u8, got %q", signed)
}
real, tk, ok := VerifyPath(signed, ip, "ua")
if !ok {
t.Fatal("valid ticket should pass VerifyPath")
}
if real != "/"+path {
t.Fatalf("realSource mismatch: got %q want %q", real, "/"+path)
}
if tk.UserID != uid || !tk.Playable || tk.Preview {
t.Fatalf("ticket payload wrong: %+v", tk)
}
}
func TestVerifyRejectsTamperedPath(t *testing.T) {
setKey(t, testKey)
signed := BuildSignedURL(1, "v3/av/a.m3u8", "1.1.1.1", "", true, false)
// 真实 path 已加密进 token,无法替换 path;篡改 token 任一字节都必须导致解密/校验失败。
if _, _, ok := VerifyPath(tamperToken(signed), "1.1.1.1", ""); ok {
t.Fatal("tampered ticket must not validate")
}
}
func TestVerifyRejectsWrongIP(t *testing.T) {
setKey(t, testKey)
signed := BuildSignedURL(1, "v3/av/a.m3u8", "1.1.1.1", "", true, false)
if _, _, ok := VerifyPath(signed, "9.9.9.9", ""); ok {
t.Fatal("ticket must not validate from a different IP")
}
}
func TestVerifyRejectsExpired(t *testing.T) {
setKey(t, testKey)
tk := &Ticket{
UserID: 1,
Path: "v3/av/a.m3u8",
ExpireAt: time.Now().Add(-time.Minute).Unix(), // 已过期
Playable: true,
}
if _, _, ok := VerifyPath(signedWith(t, tk, "1.1.1.1", ""), "1.1.1.1", ""); ok {
t.Fatal("expired ticket must not validate")
}
}
func TestVerifyRejectsWrongKey(t *testing.T) {
setKey(t, testKey)
signed := BuildSignedURL(1, "v3/av/a.m3u8", "1.1.1.1", "", true, false)
setKey(t, "another-key-totally-different-000000000000")
if _, _, ok := VerifyPath(signed, "1.1.1.1", ""); ok {
t.Fatal("ticket signed with a different key must not validate")
}
}
func TestVerifyRejectsGarbageAndBarePath(t *testing.T) {
setKey(t, testKey)
cases := []string{
"",
"/bktuserup/sp/a.m3u8", // 裸 path,无票据前缀
"/tk1//bktuserup/sp/a.m3u8", // 空票据段
"/tk1/not-base64!!/v3/av/a.m3u8", // 非法 base64
"/tk1/YWJj/v3/av/a.m3u8", // 可解码但非合法密文
}
for _, in := range cases {
if _, _, ok := VerifyPath(in, "1.1.1.1", ""); ok {
t.Fatalf("input %q must not validate", in)
}
}
}
func TestDisabledWhenNoKey(t *testing.T) {
setKey(t, "")
if Enabled() {
t.Fatal("must be disabled without key")
}
if got := BuildSignedURL(1, "v3/av/a.m3u8", "1.1.1.1", "", true, false); got != "" {
t.Fatalf("must not sign without key, got %q", got)
}
if _, _, ok := VerifyPath("/tk1/x/v3/av/a.m3u8", "1.1.1.1", ""); ok {
t.Fatal("must not validate without key")
}
}
// TestStripTicket 验证非严格接口的还原:带票地址(含过期)解出真实 path、明文原样、未配密钥原样。
func TestStripTicket(t *testing.T) {
setKey(t, testKey)
const realPath = "bktuserup/sp/a.m3u8"
// 带票地址 -> 解出真实 path
signed := BuildSignedURL(1, realPath, "1.1.1.1", "", true, false)
if got := StripTicket(signed); got != "/"+realPath {
t.Fatalf("StripTicket(signed) = %q, want /%s", got, realPath)
}
// 过期票也应还原(StripTicket 不校验过期/IP)
expired := signedWith(t, &Ticket{UserID: 1, Path: realPath, ExpireAt: time.Now().Add(-time.Hour).Unix(), Playable: true}, "9.9.9.9", "")
if got := StripTicket(expired); got != "/"+realPath {
t.Fatalf("StripTicket(expired) = %q, want /%s (no expiry check)", got, realPath)
}
// 裸明文 path 原样返回
if got := StripTicket("/" + realPath); got != "/"+realPath {
t.Fatalf("StripTicket(plain) = %q, want unchanged", got)
}
// 未配置密钥时原样返回
setKey(t, "")
if got := StripTicket(signed); got != signed {
t.Fatalf("StripTicket without key should be unchanged, got %q", got)
}
}
func TestSignRejectsNonM3u8AndZeroUser(t *testing.T) {
setKey(t, testKey)
if got := BuildSignedURL(1, "v3/av/a.mp4", "1.1.1.1", "", true, false); got != "" {
t.Fatalf("non-m3u8 must not be signed, got %q", got)
}
if got := BuildSignedURL(0, "v3/av/a.m3u8", "1.1.1.1", "", true, false); got != "" {
t.Fatalf("zero user must not be signed, got %q", got)
}
}
func TestNormalizePath(t *testing.T) {
a, okA := normalizePath("/v3/av/a.m3u8")
b, okB := normalizePath("v3/av/a.m3u8")
if !okA || !okB || a != b {
t.Fatalf("leading slash should normalize equally: %q ok=%v vs %q ok=%v", a, okA, b, okB)
}
bad := []string{"", "http://x/a.m3u8", "https://x/a.m3u8", "../a.m3u8", `v3\a.m3u8`, "v3/%2e%2e/a.m3u8", "v3/av/a.mp4"}
for _, in := range bad {
if _, ok := normalizePath(in); ok {
t.Fatalf("path %q should be rejected", in)
}
}
}
func TestSignedURLPreservesLeadingSlash(t *testing.T) {
setKey(t, testKey)
// 无前导斜杠输入 -> 无前导斜杠输出(与示例 sourceURL 形态一致)。
if got := BuildSignedURL(1, "bktuserup/sp/a.m3u8", "1.1.1.1", "", true, false); strings.HasPrefix(got, "/") {
t.Fatalf("should not add leading slash, got %q", got)
}
// 有前导斜杠输入 -> 保留前导斜杠(应用前缀在斜杠之后)。
if got := BuildSignedURL(1, "/bktuserup/sp/a.m3u8", "1.1.1.1", "", true, false); !strings.HasPrefix(got, "/"+appPrefix()) {
t.Fatalf("should preserve leading slash, got %q", got)
}
}
// TestSignedURLHasAppPrefix 验证带票地址最前面带 JHA-{appid} 前缀,且验票/StripTicket 能剥前缀还原。
func TestSignedURLHasAppPrefix(t *testing.T) {
setKey(t, testKey)
const realPath = "bktuserup/sp/a.m3u8"
signed := BuildSignedURL(1, realPath, "1.1.1.1", "", true, false)
if !strings.HasPrefix(signed, appPrefix()) {
t.Fatalf("signed url should start with %q, got %q", appPrefix(), signed)
}
if real, _, ok := VerifyPath(signed, "1.1.1.1", ""); !ok || real != "/"+realPath {
t.Fatalf("VerifyPath with app prefix: ok=%v real=%q", ok, real)
}
if got := StripTicket(signed); got != "/"+realPath {
t.Fatalf("StripTicket with app prefix = %q, want /%s", got, realPath)
}
}
// TestFallbackSignsNestedVideoInfo 验证反射兜底能穿过未实现 M3u8Signable 的外层 wrapper -> 切片 -> 指针,
// 就地改写 VideoInfo,且改写后的地址可被 VerifyPath 还原并校验通过。
func TestFallbackSignsNestedVideoInfo(t *testing.T) {
setKey(t, testKey)
v1 := &vidmod.VideoInfo{}
v1.SourceURL = "bktuserup/sp/a.m3u8"
v1.H265Url = "bktuserup/sp/a265.m3u8"
v1.PreviewURL = "bktuserup/sp/p.m3u8"
// 匿名 wrapper 不实现 M3u8SignablesignData 会落到反射兜底 signByReflect。
wrapper := struct{ List []*vidmod.VideoInfo }{List: []*vidmod.VideoInfo{v1}}
signData(&ticketSigner{userID: 1, ip: "1.1.1.1", ua: "ua"}, &wrapper)
for _, raw := range []string{v1.SourceURL, v1.H265Url, v1.PreviewURL} {
if !strings.HasPrefix(raw, "JHA-") {
t.Fatalf("field not signed: %q", raw)
}
if _, _, ok := VerifyPath("/"+raw, "1.1.1.1", "ua"); !ok {
t.Fatalf("signed field should verify: %q", raw)
}
}
}
// TestFallbackSignsSliceOfValues 验证 []VideoInfoResp 值切片元素(可寻址)也能被兜底改写。
func TestFallbackSignsSliceOfValues(t *testing.T) {
setKey(t, testKey)
list := []vidmod.VideoInfoResp{{SourceURL: "bktuserup/sp/a.m3u8"}}
signByReflect(&ticketSigner{userID: 2, ip: "2.2.2.2", ua: "ua"}, reflect.ValueOf(&list), 0)
if !strings.HasPrefix(list[0].SourceURL, "JHA-") {
t.Fatal("value-slice element SourceURL should have been signed")
}
}
// TestSignDataUsesInterface 验证实现了 M3u8Signable 的响应体走零反射的接口分支即可就地签票
// (含 vidmod.VideoInfo 的三个地址字段与 previewURL 的试看语义)。
func TestSignDataUsesInterface(t *testing.T) {
setKey(t, testKey)
v := &vidmod.VideoInfo{}
v.SourceURL = "bktuserup/sp/a.m3u8"
v.H265Url = "bktuserup/sp/a265.m3u8"
v.PreviewURL = "bktuserup/sp/p.m3u8"
// *vidmod.VideoInfo 实现 M3u8Signable:应命中接口分支而非反射。
if _, ok := interface{}(v).(vidmod.M3u8Signable); !ok {
t.Fatal("*vidmod.VideoInfo should implement vidmod.M3u8Signable")
}
signData(&ticketSigner{userID: 3, ip: "3.3.3.3", ua: "ua"}, v)
for _, raw := range []string{v.SourceURL, v.H265Url, v.PreviewURL} {
if !strings.HasPrefix(raw, "JHA-") {
t.Fatalf("field not signed via interface: %q", raw)
}
}
// previewURL 必须是试看票(Preview=true, Playable=false)。
_, tk, ok := VerifyPath("/"+v.PreviewURL, "3.3.3.3", "ua")
if !ok || !tk.Preview || tk.Playable {
t.Fatalf("previewURL should be a preview ticket: ok=%v tk=%+v", ok, tk)
}
}