Files
huangguo_server/app/service/m3u8ticket/ticket.go
T
rootandClaude Opus 5 8679200f41 Initial commit
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:57:10 +08:00

378 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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]
}