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)) }