270 lines
8.2 KiB
Go
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))
|
|
}
|