Files
rootandClaude Opus 5 8679200f41 Initial commit
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:57:10 +08:00

270 lines
8.2 KiB
Go

package hevcpull
import (
"91porn-server/common/log"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/url"
"path"
"regexp"
"strconv"
"strings"
"time"
)
const (
ExpiresParam = "hevc_exp"
SignatureParam = "hevc_sig"
minSecretBytes = 32
maxSignatureTTL = 480*time.Hour + 5*time.Minute
)
var (
ErrInvalidSecret = errors.New("HEVC pull secret must contain at least 32 bytes")
ErrInvalidSignature = errors.New("invalid HEVC pull signature")
ErrExpired = errors.New("expired HEVC pull signature")
signatureTextPattern = regexp.MustCompile(`(?i)(hevc_sig(?:=|%3D))[0-9a-f]{64}`)
)
// NormalizeSource accepts only the canonical relative m3u8 paths stored by
// the video service. Absolute URLs, query strings, fragments, backslashes,
// and dot traversal are rejected so the signed path and cloud task identity
// always refer to the same source.
func NormalizeSource(source string) (string, error) {
source = strings.TrimSpace(source)
if source == "" || strings.Contains(source, "\\") {
return "", errors.New("invalid HEVC pull source")
}
parsed, err := url.Parse(source)
if err != nil ||
parsed.IsAbs() ||
parsed.Host != "" ||
parsed.User != nil ||
parsed.RawQuery != "" ||
parsed.Fragment != "" {
return "", errors.New("invalid HEVC pull source")
}
sourcePath := strings.TrimLeft(parsed.Path, "/")
if sourcePath == "" || strings.Contains(sourcePath, "\\") {
return "", errors.New("invalid HEVC pull source")
}
for _, char := range sourcePath {
if char < 0x20 || char == 0x7f {
return "", errors.New("invalid HEVC pull source")
}
}
for _, segment := range strings.Split(sourcePath, "/") {
if segment == "." || segment == ".." {
return "", errors.New("invalid HEVC pull source traversal")
}
}
normalized := strings.TrimLeft(path.Clean("/"+sourcePath), "/")
if normalized == "" ||
normalized == "." ||
path.Ext(normalized) != ".m3u8" {
return "", errors.New("invalid HEVC pull source")
}
return normalized, nil
}
// ResolveChildSource resolves a child playlist URI from a signed master
// playlist against the parent source path. Only local m3u8 paths are accepted;
// absolute URLs and query-bearing variants are rejected.
func ResolveChildSource(parentSource, childURI string) (string, error) {
parentSource, err := NormalizeSource(parentSource)
if err != nil {
return "", err
}
childURI = strings.TrimSpace(childURI)
if childURI == "" || strings.Contains(childURI, "\\") {
return "", errors.New("invalid HEVC child playlist")
}
child, err := url.Parse(childURI)
if err != nil ||
child.IsAbs() ||
child.Host != "" ||
child.User != nil ||
child.RawQuery != "" ||
child.Fragment != "" ||
child.Path == "" {
return "", errors.New("invalid HEVC child playlist")
}
parentNamespace := knownSourceNamespace(parentSource)
var resolved string
if strings.HasPrefix(child.Path, "/") {
resolved = strings.TrimLeft(child.Path, "/")
childNamespace := knownSourceNamespace(resolved)
switch parentNamespace {
case "", "sp":
// A path without a reserved prefix still resolves to the default
// SP origin. Explicitly switching to another origin is forbidden.
if childNamespace != "" && childNamespace != "sp" {
return "", errors.New("HEVC child playlist changes source namespace")
}
default:
// For non-default origins the source namespace is encoded in the
// route path. A plain root-relative URI cannot preserve that origin,
// so require the playlist to name the same namespace explicitly.
if childNamespace != parentNamespace {
return "", errors.New("ambiguous HEVC root-relative child playlist")
}
}
} else {
if parentNamespace != "" && childEscapesSourceNamespace(parentSource, child.Path) {
return "", errors.New("HEVC child playlist escapes source namespace")
}
resolved = path.Join("/", path.Dir(parentSource), child.Path)
}
resolved, err = NormalizeSource(strings.TrimLeft(resolved, "/"))
if err != nil {
return "", err
}
if !strings.HasPrefix(child.Path, "/") &&
parentNamespace != "" &&
knownSourceNamespace(resolved) != parentNamespace {
return "", errors.New("HEVC child playlist escapes source namespace")
}
return resolved, nil
}
func childEscapesSourceNamespace(parentSource, childPath string) bool {
parentDir := strings.Trim(path.Dir(parentSource), "/")
depth := 0
if parentDir != "" && parentDir != "." {
depth = len(strings.Split(parentDir, "/"))
}
for _, segment := range strings.Split(childPath, "/") {
switch segment {
case "", ".":
continue
case "..":
if depth <= 1 {
return true
}
depth--
default:
depth++
}
}
return false
}
func knownSourceNamespace(source string) string {
first, _, _ := strings.Cut(strings.TrimLeft(source, "/"), "/")
switch first {
case "sp", "pms", "laosiji", "v1", "v2", "v3":
return first
default:
return ""
}
}
// SignURL signs an exact source path for the cloud transcoder. The returned
// URL contains only the expiry and signature query parameters.
func SignURL(rawURL, secret string, expiresAt time.Time) (string, error) {
secret = strings.TrimSpace(secret)
if len([]byte(secret)) < minSecretBytes {
return "", ErrInvalidSecret
}
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return "", err
}
if parsed.Scheme == "" || parsed.Host == "" || parsed.EscapedPath() == "" {
return "", errors.New("invalid HEVC pull URL")
}
if parsed.RawQuery != "" || parsed.Fragment != "" {
return "", errors.New("HEVC pull URL must not contain query or fragment")
}
expires := expiresAt.UTC().Unix()
if expires <= 0 {
return "", errors.New("invalid HEVC pull expiry")
}
query := parsed.Query()
query.Set(ExpiresParam, strconv.FormatInt(expires, 10))
query.Set(SignatureParam, signature(parsed.EscapedPath(), expires, secret))
parsed.RawQuery = query.Encode()
return parsed.String(), nil
}
// VerifyURL validates expiry, exact path binding, and the HMAC signature.
func VerifyURL(parsed *url.URL, secret string, now time.Time) error {
secret = strings.TrimSpace(secret)
if len([]byte(secret)) < minSecretBytes {
log.Error("VerifyURL fail 1")
return ErrInvalidSecret
}
if parsed == nil || parsed.EscapedPath() == "" {
log.Error("VerifyURL fail 2", log.Any("parsed", parsed))
return ErrInvalidSignature
}
query := parsed.Query()
if len(query) != 2 ||
len(query[ExpiresParam]) != 1 ||
len(query[SignatureParam]) != 1 {
log.Error("VerifyURL fail 3", log.Any("query", query))
return ErrInvalidSignature
}
expires, err := strconv.ParseInt(query.Get(ExpiresParam), 10, 64)
if err != nil {
log.Error("VerifyURL fail 4", log.E(err))
return ErrInvalidSignature
}
now = now.UTC()
expiresAt := time.Unix(expires, 0).UTC()
if !expiresAt.After(now) {
log.Error("VerifyURL fail 5", log.Any("expires", expires))
return ErrExpired
}
if expiresAt.After(now.Add(maxSignatureTTL)) {
log.Error("VerifyURL fail 6", log.Any("expires", expires))
return ErrInvalidSignature
}
provided, err := hex.DecodeString(query.Get(SignatureParam))
if err != nil {
log.Error("VerifyURL fail 7", log.E(err))
return ErrInvalidSignature
}
expected, err := hex.DecodeString(signature(parsed.EscapedPath(), expires, secret))
if err != nil || !hmac.Equal(provided, expected) {
log.Error("VerifyURL fail 8", log.Any("expires", expires))
return ErrInvalidSignature
}
return nil
}
// RedactText removes signed pull bearer values from logs and diagnostics,
// including when the nested URL has been query-escaped by another API.
func RedactText(text string) string {
return signatureTextPattern.ReplaceAllString(text, `${1}[REDACTED]`)
}
// RedactURL returns a diagnostic form of a signed pull URL that is safe to
// persist. It is not a usable pull URL.
func RedactURL(rawURL string) string {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return RedactText(rawURL)
}
query := parsed.Query()
if query.Has(SignatureParam) {
query.Set(SignatureParam, "[REDACTED]")
parsed.RawQuery = query.Encode()
}
return parsed.String()
}
func signature(escapedPath string, expires int64, secret string) string {
canonical := fmt.Sprintf("%s\n%d", escapedPath, expires)
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(canonical))
return hex.EncodeToString(mac.Sum(nil))
}