From d3fafb0c4da4955a9418bc2559591de3e0deaca9 Mon Sep 17 00:00:00 2001 From: modal-error Date: Mon, 26 Jan 2026 19:57:01 -0500 Subject: [PATCH 1/2] feat: use signed urls for videos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When using authentication for stash accesss, cast urls for airplay will not have access to cookies - meaning that Airplay will fail to pass authorization for video stream access. Updated code to sign urls for videos and streams. * testing notes: airplay from localhost won’t work, since appletv’s perspective of localhost is different, try casting from IP address (192.168.0.x:9999) or other local DNS name. --- internal/api/authentication.go | 29 ++++++++++ internal/api/resolver_model_scene.go | 36 +++++++++++- internal/api/resolver_query_scene.go | 21 ++++++- internal/api/urlbuilders/scene.go | 12 ++++ internal/manager/config/config.go | 17 ++++++ pkg/ffmpeg/stream_segmented.go | 17 ++++++ pkg/signedurl/signedurl.go | 87 ++++++++++++++++++++++++++++ 7 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 pkg/signedurl/signedurl.go diff --git a/internal/api/authentication.go b/internal/api/authentication.go index 6ad7117a1..45ffa2952 100644 --- a/internal/api/authentication.go +++ b/internal/api/authentication.go @@ -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) { diff --git a/internal/api/resolver_model_scene.go b/internal/api/resolver_model_scene.go index 2600c9538..a514807c7 100644 --- a/internal/api/resolver_model_scene.go +++ b/internal/api/resolver_model_scene.go @@ -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) { diff --git a/internal/api/resolver_query_scene.go b/internal/api/resolver_query_scene.go index 1bb8f0f96..93625d07b 100644 --- a/internal/api/resolver_query_scene.go +++ b/internal/api/resolver_query_scene.go @@ -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()) } diff --git a/internal/api/urlbuilders/scene.go b/internal/api/urlbuilders/scene.go index 10c4f347c..746888b58 100644 --- a/internal/api/urlbuilders/scene.go +++ b/internal/api/urlbuilders/scene.go @@ -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" } diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 35534f119..d578c5b6c 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -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" @@ -1169,6 +1172,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 { diff --git a/pkg/ffmpeg/stream_segmented.go b/pkg/ffmpeg/stream_segmented.go index f35b960ab..0de1aefb3 100644 --- a/pkg/ffmpeg/stream_segmented.go +++ b/pkg/ffmpeg/stream_segmented.go @@ -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 != "" { diff --git a/pkg/signedurl/signedurl.go b/pkg/signedurl/signedurl.go new file mode 100644 index 000000000..17f6a0076 --- /dev/null +++ b/pkg/signedurl/signedurl.go @@ -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 +} \ No newline at end of file From 82b1dc948e5b241e45a85cbe4e40064d862384cb Mon Sep 17 00:00:00 2001 From: modal-error Date: Tue, 27 Jan 2026 07:23:52 -0500 Subject: [PATCH 2/2] fix linter --- internal/api/resolver_model_scene.go | 8 ++++---- pkg/signedurl/signedurl.go | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/api/resolver_model_scene.go b/internal/api/resolver_model_scene.go index a514807c7..85615c423 100644 --- a/internal/api/resolver_model_scene.go +++ b/internal/api/resolver_model_scene.go @@ -108,21 +108,21 @@ 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() diff --git a/pkg/signedurl/signedurl.go b/pkg/signedurl/signedurl.go index 17f6a0076..206774c56 100644 --- a/pkg/signedurl/signedurl.go +++ b/pkg/signedurl/signedurl.go @@ -71,7 +71,7 @@ func VerifyURL(rawURL string, secret []byte) (bool, error) { 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]}, "/") @@ -84,4 +84,4 @@ func VerifyURL(rawURL string, secret []byte) (bool, error) { expectedSig := hex.EncodeToString(h.Sum(nil)) return hmac.Equal([]byte(sig), []byte(expectedSig)), nil -} \ No newline at end of file +}