This commit is contained in:
modal-error 2026-02-07 00:49:54 +08:00 committed by GitHub
commit 6c17a94390
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 214 additions and 5 deletions

View file

@ -12,6 +12,7 @@ import (
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/session"
"github.com/stashapp/stash/pkg/signedurl"
)
const (
@ -29,6 +30,24 @@ func allowUnauthenticated(r *http.Request) bool {
return strings.HasPrefix(r.URL.Path, loginEndpoint) || r.URL.Path == logoutEndpoint || r.URL.Path == "/css" || strings.HasPrefix(r.URL.Path, "/assets")
}
func isSignedMediaRequest(r *http.Request) bool {
// Check if path starts with /scene/, /image/, or /gallery/
if !strings.HasPrefix(r.URL.Path, "/scene/") && !strings.HasPrefix(r.URL.Path, "/image/") && !strings.HasPrefix(r.URL.Path, "/gallery/") {
return false
}
// Check for signed URL parameters
q := r.URL.Query()
if q.Get(signedurl.ExpiresParam) == "" || q.Get(signedurl.SigParam) == "" {
return false
}
// Verify signature
c := config.GetInstance()
valid, err := signedurl.VerifyURL(r.URL.String(), c.GetJWTSignKey())
return err == nil && valid
}
func authenticateHandler() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -40,6 +59,16 @@ func authenticateHandler() func(http.Handler) http.Handler {
return
}
// Check for signed media requests
if isSignedMediaRequest(r) {
// Allow signed requests
ctx := r.Context()
ctx = session.SetCurrentUserID(ctx, c.GetUsername())
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
return
}
userID, err := manager.GetInstance().SessionStore.Authenticate(w, r)
if err != nil {
if !errors.Is(err, session.ErrUnauthorized) {

View file

@ -9,6 +9,7 @@ import (
"github.com/stashapp/stash/internal/api/urlbuilders"
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/signedurl"
)
func convertVideoFile(f models.File) (*models.VideoFile, error) {
@ -107,15 +108,29 @@ func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*ScenePat
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
config := manager.GetInstance().Config
builder := urlbuilders.NewSceneURLBuilder(baseURL, obj)
// Use configurable expiry for signed URLs (only for AirPlay-compatible formats)
expires := time.Now().Add(time.Duration(config.GetSignedURLExpiry()) * time.Second)
// AirPlay-compatible formats: use signed URLs (streaming + captions)
streamPath, err := builder.GetSignedStreamURL(config.GetJWTSignKey(), expires)
if err != nil {
return nil, err
}
captionBasePath, err := builder.GetSignedCaptionURL(config.GetJWTSignKey(), expires)
if err != nil {
return nil, err
}
// Web-only formats: use unsigned URLs (rely on cookie authentication)
screenshotPath := builder.GetScreenshotURL()
previewPath := builder.GetStreamPreviewURL()
streamPath := builder.GetStreamURL(config.GetAPIKey()).String()
webpPath := builder.GetStreamPreviewImageURL()
objHash := obj.GetHash(config.GetVideoFileNamingAlgorithm())
vttPath := builder.GetSpriteVTTURL(objHash)
spritePath := builder.GetSpriteURL(objHash)
funscriptPath := builder.GetFunscriptURL()
captionBasePath := builder.GetCaptionURL()
interactiveHeatmap := builder.GetInteractiveHeatmapURL()
return &ScenePathsType{
@ -296,7 +311,22 @@ func (r *sceneResolver) SceneStreams(ctx context.Context, obj *models.Scene) ([]
builder := urlbuilders.NewSceneURLBuilder(baseURL, obj)
apiKey := config.GetAPIKey()
return manager.GetSceneStreamPaths(obj, builder.GetStreamURL(apiKey), config.GetMaxStreamingTranscodeSize())
endpoints, err := manager.GetSceneStreamPaths(obj, builder.GetStreamURL(apiKey), config.GetMaxStreamingTranscodeSize())
if err != nil {
return nil, err
}
// Sign each endpoint URL
expires := time.Now().Add(time.Duration(config.GetSignedURLExpiry()) * time.Second)
for _, endpoint := range endpoints {
signedURL, err := signedurl.SignURL(endpoint.URL, config.GetJWTSignKey(), expires)
if err != nil {
return nil, err
}
endpoint.URL = signedURL
}
return endpoints, nil
}
func (r *sceneResolver) Interactive(ctx context.Context, obj *models.Scene) (bool, error) {

View file

@ -3,7 +3,9 @@ package api
import (
"context"
"fmt"
"net/url"
"strconv"
"time"
"github.com/stashapp/stash/internal/api/urlbuilders"
"github.com/stashapp/stash/internal/manager"
@ -39,7 +41,22 @@ func (r *queryResolver) SceneStreams(ctx context.Context, id *string) ([]*manage
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
builder := urlbuilders.NewSceneURLBuilder(baseURL, scene)
apiKey := config.GetAPIKey()
expires := time.Now().Add(24 * time.Hour)
signedURL, err := builder.GetSignedStreamURL(config.GetJWTSignKey(), expires)
if err != nil {
// fallback to api key
apiKey := config.GetAPIKey()
streamURL := builder.GetStreamURL(apiKey)
return manager.GetSceneStreamPaths(scene, streamURL, config.GetMaxStreamingTranscodeSize())
}
return manager.GetSceneStreamPaths(scene, builder.GetStreamURL(apiKey), config.GetMaxStreamingTranscodeSize())
u, err := url.Parse(signedURL)
if err != nil {
// fallback
apiKey := config.GetAPIKey()
streamURL := builder.GetStreamURL(apiKey)
return manager.GetSceneStreamPaths(scene, streamURL, config.GetMaxStreamingTranscodeSize())
}
return manager.GetSceneStreamPaths(scene, u, config.GetMaxStreamingTranscodeSize())
}

View file

@ -4,8 +4,10 @@ import (
"fmt"
"net/url"
"strconv"
"time"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/signedurl"
)
type SceneURLBuilder struct {
@ -37,6 +39,16 @@ func (b SceneURLBuilder) GetStreamURL(apiKey string) *url.URL {
return u
}
func (b SceneURLBuilder) GetSignedStreamURL(secret []byte, expires time.Time) (string, error) {
rawURL := fmt.Sprintf("%s/scene/%s/stream", b.BaseURL, b.SceneID)
return signedurl.SignURL(rawURL, secret, expires)
}
func (b SceneURLBuilder) GetSignedCaptionURL(secret []byte, expires time.Time) (string, error) {
rawURL := b.GetCaptionURL()
return signedurl.SignURL(rawURL, secret, expires)
}
func (b SceneURLBuilder) GetStreamPreviewURL() string {
return b.BaseURL + "/scene/" + b.SceneID + "/preview"
}

View file

@ -43,6 +43,9 @@ const (
Password = "password"
MaxSessionAge = "max_session_age"
SignedURLExpiry = "signed_url_expiry"
signedURLExpiryDefault = 60 * 60 * 24 // 24 hours in seconds
// SFWContentMode mode config key
SFWContentMode = "sfw_content_mode"
@ -1170,6 +1173,20 @@ func (i *Config) GetMaxSessionAge() int {
return ret
}
// GetSignedURLExpiry gets the expiry time for signed URLs, in seconds.
func (i *Config) GetSignedURLExpiry() int {
i.RLock()
defer i.RUnlock()
ret := signedURLExpiryDefault
v := i.forKey(SignedURLExpiry)
if v.Exists(SignedURLExpiry) {
ret = v.Int(SignedURLExpiry)
}
return ret
}
// GetCustomServedFolders gets the map of custom paths to their applicable
// filesystem locations
func (i *Config) GetCustomServedFolders() utils.URLMap {

View file

@ -20,6 +20,7 @@ import (
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/signedurl"
"github.com/stashapp/stash/pkg/utils"
"github.com/zencoder/go-dash/v3/mpd"
@ -434,6 +435,8 @@ func serveHLSManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request,
urlQuery := url.Values{}
apikey := r.URL.Query().Get(apiKeyParamKey)
expires := r.URL.Query().Get(signedurl.ExpiresParam)
sig := r.URL.Query().Get(signedurl.SigParam)
if resolution != "" {
urlQuery.Set(resolutionParamKey, resolution)
@ -443,6 +446,12 @@ func serveHLSManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request,
if apikey != "" {
urlQuery.Set(apiKeyParamKey, apikey)
}
if expires != "" {
urlQuery.Set(signedurl.ExpiresParam, expires)
}
if sig != "" {
urlQuery.Set(signedurl.SigParam, sig)
}
urlQueryString := ""
if len(urlQuery) > 0 {
@ -531,9 +540,17 @@ func serveDASHManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request
// TODO - this needs to be handled outside of this package
apikey := r.URL.Query().Get(apiKeyParamKey)
expires := r.URL.Query().Get(signedurl.ExpiresParam)
sig := r.URL.Query().Get(signedurl.SigParam)
if apikey != "" {
urlQuery.Set(apiKeyParamKey, apikey)
}
if expires != "" {
urlQuery.Set(signedurl.ExpiresParam, expires)
}
if sig != "" {
urlQuery.Set(signedurl.SigParam, sig)
}
maxTranscodeSize := sm.config.GetMaxStreamingTranscodeSize().GetMaxResolution()
if resolution != "" {

View file

@ -0,0 +1,87 @@
package signedurl
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/url"
"strconv"
"strings"
"time"
)
const (
ExpiresParam = "expires"
SigParam = "signature"
)
// SignURL signs a URL with an expiration time using HMAC-SHA256
func SignURL(rawURL string, secret []byte, expires time.Time) (string, error) {
u, err := url.Parse(rawURL)
if err != nil {
return "", err
}
// Add expires parameter
q := u.Query()
q.Set(ExpiresParam, strconv.FormatInt(expires.Unix(), 10))
u.RawQuery = q.Encode()
// Create the string to sign: path + ?expires=...
signString := u.Path + "?" + ExpiresParam + "=" + q.Get(ExpiresParam)
// Generate HMAC
h := hmac.New(sha256.New, secret)
h.Write([]byte(signString))
signature := hex.EncodeToString(h.Sum(nil))
// Add signature to query
q.Set(SigParam, signature)
u.RawQuery = q.Encode()
return u.String(), nil
}
// VerifyURL verifies a signed URL, allowing for path suffixes (e.g., .mp4, .webm, /segment.ts)
func VerifyURL(rawURL string, secret []byte) (bool, error) {
u, err := url.Parse(rawURL)
if err != nil {
return false, err
}
q := u.Query()
expiresStr := q.Get(ExpiresParam)
sig := q.Get(SigParam)
if expiresStr == "" || sig == "" {
return false, nil
}
expires, err := strconv.ParseInt(expiresStr, 10, 64)
if err != nil {
return false, err
}
if time.Now().Unix() > expires {
return false, nil
}
// Find the base path: /scene/{id}/{action}, /image/{id}/{action}, or /gallery/{id}/{action}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) < 3 || (parts[0] != "scene" && parts[0] != "image" && parts[0] != "gallery") {
return false, nil
}
// For scene/image/gallery paths, the base path is /{type}/{id}/{action}
basePath := "/" + strings.Join([]string{parts[0], parts[1], parts[2]}, "/")
// Recreate the string to sign: path + ?expires=...
signString := basePath + "?" + ExpiresParam + "=" + expiresStr
// Verify HMAC
h := hmac.New(sha256.New, secret)
h.Write([]byte(signString))
expectedSig := hex.EncodeToString(h.Sum(nil))
return hmac.Equal([]byte(sig), []byte(expectedSig)), nil
}