mirror of
https://github.com/stashapp/stash.git
synced 2026-02-08 00:12:55 +01:00
Merge 82b1dc948e into 8dec195c2d
This commit is contained in:
commit
6c17a94390
7 changed files with 214 additions and 5 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
87
pkg/signedurl/signedurl.go
Normal file
87
pkg/signedurl/signedurl.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue