Improve caching, HTTP headers and URL handling (#3594)

* Fix relative URLs
* Improve login base URL and redirects
* Prevent duplicate customlocales requests
* Improve UI base URL handling
* Improve UI embedding
* Improve CSP header
* Add Cache-Control headers to all responses
* Improve CORS responses
* Improve authentication handler
* Add back media timestamp suffixes
* Fix default image handling
* Add default param to other image URLs
This commit is contained in:
DingDongSoLong4 2023-04-19 05:01:32 +02:00 committed by GitHub
parent 87abe8c38c
commit b4b7cf02b6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
74 changed files with 808 additions and 782 deletions

View file

@ -34,7 +34,7 @@ func main() {
}() }()
go handleSignals() go handleSignals()
desktop.Start(manager.GetInstance(), &manager.FaviconProvider{UIBox: ui.UIBox}) desktop.Start(manager.GetInstance(), &ui.FaviconProvider)
blockForever() blockForever()
} }

2
go.mod
View file

@ -24,7 +24,6 @@ require (
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8
github.com/remeh/sizedwaitgroup v1.0.0 github.com/remeh/sizedwaitgroup v1.0.0
github.com/robertkrimen/otto v0.0.0-20200922221731-ef014fd054ac github.com/robertkrimen/otto v0.0.0-20200922221731-ef014fd054ac
github.com/rs/cors v1.6.0
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f
github.com/sirupsen/logrus v1.8.1 github.com/sirupsen/logrus v1.8.1
github.com/spf13/afero v1.8.2 // indirect github.com/spf13/afero v1.8.2 // indirect
@ -48,6 +47,7 @@ require (
require ( require (
github.com/asticode/go-astisub v0.20.0 github.com/asticode/go-astisub v0.20.0
github.com/doug-martin/goqu/v9 v9.18.0 github.com/doug-martin/goqu/v9 v9.18.0
github.com/go-chi/cors v1.2.1
github.com/go-chi/httplog v0.2.1 github.com/go-chi/httplog v0.2.1
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4
github.com/hashicorp/golang-lru v0.5.4 github.com/hashicorp/golang-lru v0.5.4

4
go.sum
View file

@ -242,6 +242,8 @@ github.com/go-chi/chi v4.0.2+incompatible h1:maB6vn6FqCxrpz4FqWdh4+lwpyZIQS7YEAU
github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/chi/v5 v5.0.0 h1:DBPx88FjZJH3FsICfDAfIfnb7XxKIYVGG6lOPlhENAg= github.com/go-chi/chi/v5 v5.0.0 h1:DBPx88FjZJH3FsICfDAfIfnb7XxKIYVGG6lOPlhENAg=
github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs= github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/httplog v0.2.1 h1:KgCtIUkYNlfIsUPzE3utxd1KDKOvCrnAKaqdo0rmrh0= github.com/go-chi/httplog v0.2.1 h1:KgCtIUkYNlfIsUPzE3utxd1KDKOvCrnAKaqdo0rmrh0=
github.com/go-chi/httplog v0.2.1/go.mod h1:JyHOFO9twSfGoTin/RoP25Lx2a9Btq10ug+sgxe0+bo= github.com/go-chi/httplog v0.2.1/go.mod h1:JyHOFO9twSfGoTin/RoP25Lx2a9Btq10ug+sgxe0+bo=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
@ -668,8 +670,6 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L
github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rs/cors v1.6.0 h1:G9tHG9lebljV9mfp9SNPDL36nCDxmo3zTlAf1YgvzmI=
github.com/rs/cors v1.6.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=

View file

@ -5,6 +5,7 @@ import (
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"path"
"strings" "strings"
"github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager"
@ -13,11 +14,6 @@ import (
"github.com/stashapp/stash/pkg/session" "github.com/stashapp/stash/pkg/session"
) )
const (
loginEndPoint = "/login"
logoutEndPoint = "/logout"
)
const ( const (
tripwireActivatedErrMsg = "Stash is exposed to the public internet without authentication, and is not serving any more content to protect your privacy. " + tripwireActivatedErrMsg = "Stash is exposed to the public internet without authentication, and is not serving any more content to protect your privacy. " +
"More information and fixes are available at https://docs.stashapp.cc/networking/authentication-required-when-accessing-stash-from-the-internet" "More information and fixes are available at https://docs.stashapp.cc/networking/authentication-required-when-accessing-stash-from-the-internet"
@ -30,7 +26,7 @@ const (
func allowUnauthenticated(r *http.Request) bool { func allowUnauthenticated(r *http.Request) bool {
// #2715 - allow access to UI files // #2715 - allow access to UI files
return strings.HasPrefix(r.URL.Path, loginEndPoint) || r.URL.Path == logoutEndPoint || r.URL.Path == "/css" || strings.HasPrefix(r.URL.Path, "/assets") return strings.HasPrefix(r.URL.Path, loginEndpoint) || r.URL.Path == logoutEndpoint || r.URL.Path == "/css" || strings.HasPrefix(r.URL.Path, "/assets")
} }
func authenticateHandler() func(http.Handler) http.Handler { func authenticateHandler() func(http.Handler) http.Handler {
@ -38,38 +34,41 @@ func authenticateHandler() func(http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c := config.GetInstance() c := config.GetInstance()
if !checkSecurityTripwireActivated(c, w) { // error if external access tripwire activated
if accessErr := session.CheckExternalAccessTripwire(c); accessErr != nil {
http.Error(w, tripwireActivatedErrMsg, http.StatusForbidden)
return return
} }
userID, err := manager.GetInstance().SessionStore.Authenticate(w, r) userID, err := manager.GetInstance().SessionStore.Authenticate(w, r)
if err != nil { if err != nil {
if errors.Is(err, session.ErrUnauthorized) { if errors.Is(err, session.ErrUnauthorized) {
w.WriteHeader(http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
_, err = w.Write([]byte(err.Error()))
if err != nil {
logger.Error(err)
}
return return
} }
// unauthorized error // unauthorized error
w.Header().Add("WWW-Authenticate", `FormBased`) w.Header().Add("WWW-Authenticate", "FormBased")
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
return return
} }
if err := session.CheckAllowPublicWithoutAuth(c, r); err != nil { if err := session.CheckAllowPublicWithoutAuth(c, r); err != nil {
var externalAccess session.ExternalAccessError var accessErr session.ExternalAccessError
switch { if errors.As(err, &accessErr) {
case errors.As(err, &externalAccess): session.LogExternalAccessError(accessErr)
securityActivateTripwireAccessedFromInternetWithoutAuth(c, externalAccess, w)
return err := c.ActivatePublicAccessTripwire(net.IP(accessErr).String())
default: if err != nil {
logger.Errorf("Error activating public access tripwire: %v", err)
}
http.Error(w, externalAccessErrMsg, http.StatusForbidden)
} else {
logger.Errorf("Error checking external access security: %v", err) logger.Errorf("Error checking external access security: %v", err)
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
return
} }
return
} }
ctx := r.Context() ctx := r.Context()
@ -77,15 +76,15 @@ func authenticateHandler() func(http.Handler) http.Handler {
if c.HasCredentials() { if c.HasCredentials() {
// authentication is required // authentication is required
if userID == "" && !allowUnauthenticated(r) { if userID == "" && !allowUnauthenticated(r) {
// authentication was not received, redirect // if graphql or a non-webpage was requested, we just return a forbidden error
// if graphql was requested, we just return a forbidden error ext := path.Ext(r.URL.Path)
if r.URL.Path == "/graphql" { if r.URL.Path == gqlEndpoint || (ext != "" && ext != ".html") {
w.Header().Add("WWW-Authenticate", `FormBased`) w.Header().Add("WWW-Authenticate", "FormBased")
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
return return
} }
prefix := getProxyPrefix(r.Header) prefix := getProxyPrefix(r)
// otherwise redirect to the login page // otherwise redirect to the login page
returnURL := url.URL{ returnURL := url.URL{
@ -95,7 +94,7 @@ func authenticateHandler() func(http.Handler) http.Handler {
q := make(url.Values) q := make(url.Values)
q.Set(returnURLParam, returnURL.String()) q.Set(returnURLParam, returnURL.String())
u := url.URL{ u := url.URL{
Path: prefix + "/login", Path: prefix + loginEndpoint,
RawQuery: q.Encode(), RawQuery: q.Encode(),
} }
http.Redirect(w, r, u.String(), http.StatusFound) http.Redirect(w, r, u.String(), http.StatusFound)
@ -111,31 +110,3 @@ func authenticateHandler() func(http.Handler) http.Handler {
}) })
} }
} }
func checkSecurityTripwireActivated(c *config.Instance, w http.ResponseWriter) bool {
if accessErr := session.CheckExternalAccessTripwire(c); accessErr != nil {
w.WriteHeader(http.StatusForbidden)
_, err := w.Write([]byte(tripwireActivatedErrMsg))
if err != nil {
logger.Error(err)
}
return false
}
return true
}
func securityActivateTripwireAccessedFromInternetWithoutAuth(c *config.Instance, accessErr session.ExternalAccessError, w http.ResponseWriter) {
session.LogExternalAccessError(accessErr)
err := c.ActivatePublicAccessTripwire(net.IP(accessErr).String())
if err != nil {
logger.Error(err)
}
w.WriteHeader(http.StatusForbidden)
_, err = w.Write([]byte(externalAccessErrMsg))
if err != nil {
logger.Error(err)
}
}

View file

@ -86,33 +86,38 @@ func (r *movieResolver) Synopsis(ctx context.Context, obj *models.Movie) (*strin
} }
func (r *movieResolver) FrontImagePath(ctx context.Context, obj *models.Movie) (*string, error) { func (r *movieResolver) FrontImagePath(ctx context.Context, obj *models.Movie) (*string, error) {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string) var hasImage bool
frontimagePath := urlbuilders.NewMovieURLBuilder(baseURL, obj).GetMovieFrontImageURL()
return &frontimagePath, nil
}
func (r *movieResolver) BackImagePath(ctx context.Context, obj *models.Movie) (*string, error) {
// don't return any thing if there is no back image
hasImage := false
if err := r.withReadTxn(ctx, func(ctx context.Context) error { if err := r.withReadTxn(ctx, func(ctx context.Context) error {
var err error var err error
hasImage, err = r.repository.Movie.HasBackImage(ctx, obj.ID) hasImage, err = r.repository.Movie.HasFrontImage(ctx, obj.ID)
if err != nil { return err
return err
}
return nil
}); err != nil { }); err != nil {
return nil, err return nil, err
} }
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
imagePath := urlbuilders.NewMovieURLBuilder(baseURL, obj).GetMovieFrontImageURL(hasImage)
return &imagePath, nil
}
func (r *movieResolver) BackImagePath(ctx context.Context, obj *models.Movie) (*string, error) {
var hasImage bool
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
var err error
hasImage, err = r.repository.Movie.HasBackImage(ctx, obj.ID)
return err
}); err != nil {
return nil, err
}
// don't return anything if there is no back image
if !hasImage { if !hasImage {
return nil, nil return nil, nil
} }
baseURL, _ := ctx.Value(BaseURLCtxKey).(string) baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
backimagePath := urlbuilders.NewMovieURLBuilder(baseURL, obj).GetMovieBackImageURL() imagePath := urlbuilders.NewMovieURLBuilder(baseURL, obj).GetMovieBackImageURL()
return &backimagePath, nil return &imagePath, nil
} }
func (r *movieResolver) SceneCount(ctx context.Context, obj *models.Movie) (ret *int, err error) { func (r *movieResolver) SceneCount(ctx context.Context, obj *models.Movie) (ret *int, err error) {

View file

@ -63,8 +63,17 @@ func (r *performerResolver) Birthdate(ctx context.Context, obj *models.Performer
} }
func (r *performerResolver) ImagePath(ctx context.Context, obj *models.Performer) (*string, error) { func (r *performerResolver) ImagePath(ctx context.Context, obj *models.Performer) (*string, error) {
var hasImage bool
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
var err error
hasImage, err = r.repository.Performer.HasImage(ctx, obj.ID)
return err
}); err != nil {
return nil, err
}
baseURL, _ := ctx.Value(BaseURLCtxKey).(string) baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
imagePath := urlbuilders.NewPerformerURLBuilder(baseURL, obj).GetPerformerImageURL() imagePath := urlbuilders.NewPerformerURLBuilder(baseURL, obj).GetPerformerImageURL(hasImage)
return &imagePath, nil return &imagePath, nil
} }

View file

@ -178,8 +178,8 @@ func formatFingerprint(fp interface{}) string {
func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*ScenePathsType, error) { func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*ScenePathsType, error) {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string) baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
config := manager.GetInstance().Config config := manager.GetInstance().Config
builder := urlbuilders.NewSceneURLBuilder(baseURL, obj.ID) builder := urlbuilders.NewSceneURLBuilder(baseURL, obj)
screenshotPath := builder.GetScreenshotURL(obj.UpdatedAt) screenshotPath := builder.GetScreenshotURL()
previewPath := builder.GetStreamPreviewURL() previewPath := builder.GetStreamPreviewURL()
streamPath := builder.GetStreamURL(config.GetAPIKey()).String() streamPath := builder.GetStreamURL(config.GetAPIKey()).String()
webpPath := builder.GetStreamPreviewImageURL() webpPath := builder.GetStreamPreviewImageURL()
@ -370,7 +370,7 @@ func (r *sceneResolver) SceneStreams(ctx context.Context, obj *models.Scene) ([]
config := manager.GetInstance().Config config := manager.GetInstance().Config
baseURL, _ := ctx.Value(BaseURLCtxKey).(string) baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
builder := urlbuilders.NewSceneURLBuilder(baseURL, obj.ID) builder := urlbuilders.NewSceneURLBuilder(baseURL, obj)
apiKey := config.GetAPIKey() apiKey := config.GetAPIKey()
return manager.GetSceneStreamPaths(obj, builder.GetStreamURL(apiKey), config.GetMaxStreamingTranscodeSize()) return manager.GetSceneStreamPaths(obj, builder.GetStreamURL(apiKey), config.GetMaxStreamingTranscodeSize())

View file

@ -48,20 +48,17 @@ func (r *sceneMarkerResolver) Tags(ctx context.Context, obj *models.SceneMarker)
func (r *sceneMarkerResolver) Stream(ctx context.Context, obj *models.SceneMarker) (string, error) { func (r *sceneMarkerResolver) Stream(ctx context.Context, obj *models.SceneMarker) (string, error) {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string) baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
sceneID := int(obj.SceneID.Int64) return urlbuilders.NewSceneMarkerURLBuilder(baseURL, obj).GetStreamURL(), nil
return urlbuilders.NewSceneURLBuilder(baseURL, sceneID).GetSceneMarkerStreamURL(obj.ID), nil
} }
func (r *sceneMarkerResolver) Preview(ctx context.Context, obj *models.SceneMarker) (string, error) { func (r *sceneMarkerResolver) Preview(ctx context.Context, obj *models.SceneMarker) (string, error) {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string) baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
sceneID := int(obj.SceneID.Int64) return urlbuilders.NewSceneMarkerURLBuilder(baseURL, obj).GetPreviewURL(), nil
return urlbuilders.NewSceneURLBuilder(baseURL, sceneID).GetSceneMarkerStreamPreviewURL(obj.ID), nil
} }
func (r *sceneMarkerResolver) Screenshot(ctx context.Context, obj *models.SceneMarker) (string, error) { func (r *sceneMarkerResolver) Screenshot(ctx context.Context, obj *models.SceneMarker) (string, error) {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string) baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
sceneID := int(obj.SceneID.Int64) return urlbuilders.NewSceneMarkerURLBuilder(baseURL, obj).GetScreenshotURL(), nil
return urlbuilders.NewSceneURLBuilder(baseURL, sceneID).GetSceneMarkerStreamScreenshotURL(obj.ID), nil
} }
func (r *sceneMarkerResolver) CreatedAt(ctx context.Context, obj *models.SceneMarker) (*time.Time, error) { func (r *sceneMarkerResolver) CreatedAt(ctx context.Context, obj *models.SceneMarker) (*time.Time, error) {

View file

@ -27,9 +27,6 @@ func (r *studioResolver) URL(ctx context.Context, obj *models.Studio) (*string,
} }
func (r *studioResolver) ImagePath(ctx context.Context, obj *models.Studio) (*string, error) { func (r *studioResolver) ImagePath(ctx context.Context, obj *models.Studio) (*string, error) {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
imagePath := urlbuilders.NewStudioURLBuilder(baseURL, obj).GetStudioImageURL()
var hasImage bool var hasImage bool
if err := r.withReadTxn(ctx, func(ctx context.Context) error { if err := r.withReadTxn(ctx, func(ctx context.Context) error {
var err error var err error
@ -39,11 +36,8 @@ func (r *studioResolver) ImagePath(ctx context.Context, obj *models.Studio) (*st
return nil, err return nil, err
} }
// indicate that image is missing by setting default query param to true baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
if !hasImage { imagePath := urlbuilders.NewStudioURLBuilder(baseURL, obj).GetStudioImageURL(hasImage)
imagePath += "?default=true"
}
return &imagePath, nil return &imagePath, nil
} }

View file

@ -111,8 +111,17 @@ func (r *tagResolver) PerformerCount(ctx context.Context, obj *models.Tag) (ret
} }
func (r *tagResolver) ImagePath(ctx context.Context, obj *models.Tag) (*string, error) { func (r *tagResolver) ImagePath(ctx context.Context, obj *models.Tag) (*string, error) {
var hasImage bool
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
var err error
hasImage, err = r.repository.Performer.HasImage(ctx, obj.ID)
return err
}); err != nil {
return nil, err
}
baseURL, _ := ctx.Value(BaseURLCtxKey).(string) baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
imagePath := urlbuilders.NewTagURLBuilder(baseURL, obj).GetTagImageURL() imagePath := urlbuilders.NewTagURLBuilder(baseURL, obj).GetTagImageURL(hasImage)
return &imagePath, nil return &imagePath, nil
} }

View file

@ -34,7 +34,7 @@ func (r *queryResolver) SceneStreams(ctx context.Context, id *string) ([]*manage
config := manager.GetInstance().Config config := manager.GetInstance().Config
baseURL, _ := ctx.Value(BaseURLCtxKey).(string) baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
builder := urlbuilders.NewSceneURLBuilder(baseURL, scene.ID) builder := urlbuilders.NewSceneURLBuilder(baseURL, scene)
apiKey := config.GetAPIKey() apiKey := config.GetAPIKey()
return manager.GetSceneStreamPaths(scene, builder.GetStreamURL(apiKey), config.GetMaxStreamingTranscodeSize()) return manager.GetSceneStreamPaths(scene, builder.GetStreamURL(apiKey), config.GetMaxStreamingTranscodeSize())

View file

@ -8,7 +8,6 @@ import (
"net/http" "net/http"
"os/exec" "os/exec"
"strconv" "strconv"
"syscall"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager"
@ -19,6 +18,7 @@ import (
"github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/txn" "github.com/stashapp/stash/pkg/txn"
"github.com/stashapp/stash/pkg/utils"
) )
type ImageFinder interface { type ImageFinder interface {
@ -51,12 +51,10 @@ func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) {
img := r.Context().Value(imageKey).(*models.Image) img := r.Context().Value(imageKey).(*models.Image)
filepath := manager.GetInstance().Paths.Generated.GetThumbnailPath(img.Checksum, models.DefaultGthumbWidth) filepath := manager.GetInstance().Paths.Generated.GetThumbnailPath(img.Checksum, models.DefaultGthumbWidth)
w.Header().Add("Cache-Control", "max-age=604800000")
// if the thumbnail doesn't exist, encode on the fly // if the thumbnail doesn't exist, encode on the fly
exists, _ := fsutil.FileExists(filepath) exists, _ := fsutil.FileExists(filepath)
if exists { if exists {
http.ServeFile(w, r, filepath) utils.ServeStaticFile(w, r, filepath)
} else { } else {
const useDefault = true const useDefault = true
@ -88,13 +86,13 @@ func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) {
// write the generated thumbnail to disk if enabled // write the generated thumbnail to disk if enabled
if manager.GetInstance().Config.IsWriteImageThumbnails() { if manager.GetInstance().Config.IsWriteImageThumbnails() {
logger.Debugf("writing thumbnail to disk: %s", img.Path) logger.Debugf("writing thumbnail to disk: %s", img.Path)
if err := fsutil.WriteFile(filepath, data); err != nil { if err := fsutil.WriteFile(filepath, data); err == nil {
logger.Errorf("error writing thumbnail for image %s: %v", img.Path, err) utils.ServeStaticFile(w, r, filepath)
return
} }
logger.Errorf("error writing thumbnail for image %s: %v", img.Path, err)
} }
if n, err := w.Write(data); err != nil && !errors.Is(err, syscall.EPIPE) { utils.ServeStaticContent(w, r, data)
logger.Errorf("error serving thumbnail (wrote %v bytes out of %v): %v", n, len(data), err)
}
} }
} }
@ -131,8 +129,8 @@ func (rs imageRoutes) serveImage(w http.ResponseWriter, r *http.Request, i *mode
// fall back to static image // fall back to static image
f, _ := static.Image.Open(defaultImageImage) f, _ := static.Image.Open(defaultImageImage)
defer f.Close() defer f.Close()
stat, _ := f.Stat() image, _ := io.ReadAll(f)
http.ServeContent(w, r, "image.svg", stat.ModTime(), f.(io.ReadSeeker)) utils.ServeImage(w, r, image)
} }
// endregion // endregion

View file

@ -58,9 +58,7 @@ func (rs movieRoutes) FrontImage(w http.ResponseWriter, r *http.Request) {
image, _ = utils.ProcessBase64Image(models.DefaultMovieImage) image, _ = utils.ProcessBase64Image(models.DefaultMovieImage)
} }
if err := utils.ServeImage(image, w, r); err != nil { utils.ServeImage(w, r, image)
logger.Warnf("error serving movie front image: %v", err)
}
} }
func (rs movieRoutes) BackImage(w http.ResponseWriter, r *http.Request) { func (rs movieRoutes) BackImage(w http.ResponseWriter, r *http.Request) {
@ -85,9 +83,7 @@ func (rs movieRoutes) BackImage(w http.ResponseWriter, r *http.Request) {
image, _ = utils.ProcessBase64Image(models.DefaultMovieImage) image, _ = utils.ProcessBase64Image(models.DefaultMovieImage)
} }
if err := utils.ServeImage(image, w, r); err != nil { utils.ServeImage(w, r, image)
logger.Warnf("error serving movie back image: %v", err)
}
} }
func (rs movieRoutes) MovieCtx(next http.Handler) http.Handler { func (rs movieRoutes) MovieCtx(next http.Handler) http.Handler {

View file

@ -54,13 +54,11 @@ func (rs performerRoutes) Image(w http.ResponseWriter, r *http.Request) {
} }
} }
if len(image) == 0 || defaultParam == "true" { if len(image) == 0 {
image, _ = getRandomPerformerImageUsingName(performer.Name, performer.Gender, config.GetInstance().GetCustomPerformerImageLocation()) image, _ = getRandomPerformerImageUsingName(performer.Name, performer.Gender, config.GetInstance().GetCustomPerformerImageLocation())
} }
if err := utils.ServeImage(image, w, r); err != nil { utils.ServeImage(w, r, image)
logger.Warnf("error serving performer image: %v", err)
}
} }
func (rs performerRoutes) PerformerCtx(next http.Handler) http.Handler { func (rs performerRoutes) PerformerCtx(next http.Handler) http.Handler {

View file

@ -88,24 +88,12 @@ func (rs sceneRoutes) Routes() chi.Router {
// region Handlers // region Handlers
func (rs sceneRoutes) StreamDirect(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) StreamDirect(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene) scene := r.Context().Value(sceneKey).(*models.Scene)
// #3526 - return 404 if the scene does not have any files ss := manager.SceneServer{
if scene.Path == "" { TxnManager: rs.txnManager,
w.WriteHeader(http.StatusNotFound) SceneCoverGetter: rs.sceneFinder,
return
} }
ss.StreamSceneDirect(scene, w, r)
sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
filepath := manager.GetInstance().Paths.Scene.GetStreamPath(scene.Path, sceneHash)
streamRequestCtx := ffmpeg.NewStreamRequestContext(w, r)
// #2579 - hijacking and closing the connection here causes video playback to fail in Safari
// We trust that the request context will be closed, so we don't need to call Cancel on the
// returned context here.
_ = manager.GetInstance().ReadLockManager.ReadLock(streamRequestCtx, filepath)
http.ServeFile(w, r, filepath)
} }
func (rs sceneRoutes) StreamMp4(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) StreamMp4(w http.ResponseWriter, r *http.Request) {
@ -266,22 +254,16 @@ func (rs sceneRoutes) Preview(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene) scene := r.Context().Value(sceneKey).(*models.Scene)
sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()) sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
filepath := manager.GetInstance().Paths.Scene.GetVideoPreviewPath(sceneHash) filepath := manager.GetInstance().Paths.Scene.GetVideoPreviewPath(sceneHash)
serveFileNoCache(w, r, filepath)
}
// serveFileNoCache serves the provided file, ensuring that the response utils.ServeStaticFile(w, r, filepath)
// contains headers to prevent caching.
func serveFileNoCache(w http.ResponseWriter, r *http.Request, filepath string) {
w.Header().Add("Cache-Control", "no-cache")
http.ServeFile(w, r, filepath)
} }
func (rs sceneRoutes) Webp(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) Webp(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene) scene := r.Context().Value(sceneKey).(*models.Scene)
sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()) sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
filepath := manager.GetInstance().Paths.Scene.GetWebpPreviewPath(sceneHash) filepath := manager.GetInstance().Paths.Scene.GetWebpPreviewPath(sceneHash)
http.ServeFile(w, r, filepath)
utils.ServeStaticFile(w, r, filepath)
} }
func (rs sceneRoutes) getChapterVttTitle(ctx context.Context, marker *models.SceneMarker) (*string, error) { func (rs sceneRoutes) getChapterVttTitle(ctx context.Context, marker *models.SceneMarker) (*string, error) {
@ -355,7 +337,7 @@ func (rs sceneRoutes) VttChapter(w http.ResponseWriter, r *http.Request) {
vtt := strings.Join(vttLines, "\n") vtt := strings.Join(vttLines, "\n")
w.Header().Set("Content-Type", "text/vtt") w.Header().Set("Content-Type", "text/vtt")
_, _ = w.Write([]byte(vtt)) utils.ServeStaticContent(w, r, []byte(vtt))
} }
func (rs sceneRoutes) VttThumbs(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) VttThumbs(w http.ResponseWriter, r *http.Request) {
@ -366,9 +348,10 @@ func (rs sceneRoutes) VttThumbs(w http.ResponseWriter, r *http.Request) {
} else { } else {
sceneHash = chi.URLParam(r, "sceneHash") sceneHash = chi.URLParam(r, "sceneHash")
} }
w.Header().Set("Content-Type", "text/vtt")
filepath := manager.GetInstance().Paths.Scene.GetSpriteVttFilePath(sceneHash) filepath := manager.GetInstance().Paths.Scene.GetSpriteVttFilePath(sceneHash)
http.ServeFile(w, r, filepath)
w.Header().Set("Content-Type", "text/vtt")
utils.ServeStaticFile(w, r, filepath)
} }
func (rs sceneRoutes) VttSprite(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) VttSprite(w http.ResponseWriter, r *http.Request) {
@ -379,23 +362,24 @@ func (rs sceneRoutes) VttSprite(w http.ResponseWriter, r *http.Request) {
} else { } else {
sceneHash = chi.URLParam(r, "sceneHash") sceneHash = chi.URLParam(r, "sceneHash")
} }
w.Header().Set("Content-Type", "image/jpeg")
filepath := manager.GetInstance().Paths.Scene.GetSpriteImageFilePath(sceneHash) filepath := manager.GetInstance().Paths.Scene.GetSpriteImageFilePath(sceneHash)
http.ServeFile(w, r, filepath)
utils.ServeStaticFile(w, r, filepath)
} }
func (rs sceneRoutes) Funscript(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) Funscript(w http.ResponseWriter, r *http.Request) {
s := r.Context().Value(sceneKey).(*models.Scene) s := r.Context().Value(sceneKey).(*models.Scene)
funscript := video.GetFunscriptPath(s.Path) filepath := video.GetFunscriptPath(s.Path)
serveFileNoCache(w, r, funscript)
utils.ServeStaticFile(w, r, filepath)
} }
func (rs sceneRoutes) InteractiveHeatmap(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) InteractiveHeatmap(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene) scene := r.Context().Value(sceneKey).(*models.Scene)
sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()) sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
w.Header().Set("Content-Type", "image/png")
filepath := manager.GetInstance().Paths.Scene.GetInteractiveHeatmapPath(sceneHash) filepath := manager.GetInstance().Paths.Scene.GetInteractiveHeatmapPath(sceneHash)
http.ServeFile(w, r, filepath)
utils.ServeStaticFile(w, r, filepath)
} }
func (rs sceneRoutes) Caption(w http.ResponseWriter, r *http.Request, lang string, ext string) { func (rs sceneRoutes) Caption(w http.ResponseWriter, r *http.Request, lang string, ext string) {
@ -434,16 +418,17 @@ func (rs sceneRoutes) Caption(w http.ResponseWriter, r *http.Request, lang strin
return return
} }
var b bytes.Buffer var buf bytes.Buffer
err = sub.WriteToWebVTT(&b)
err = sub.WriteToWebVTT(&buf)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
w.Header().Set("Content-Type", "text/vtt") w.Header().Set("Content-Type", "text/vtt")
w.Header().Add("Cache-Control", "no-cache") utils.ServeStaticContent(w, r, buf.Bytes())
_, _ = b.WriteTo(w) return
} }
} }
@ -483,7 +468,7 @@ func (rs sceneRoutes) SceneMarkerStream(w http.ResponseWriter, r *http.Request)
} }
filepath := manager.GetInstance().Paths.SceneMarkers.GetVideoPreviewPath(sceneHash, int(sceneMarker.Seconds)) filepath := manager.GetInstance().Paths.SceneMarkers.GetVideoPreviewPath(sceneHash, int(sceneMarker.Seconds))
http.ServeFile(w, r, filepath) utils.ServeStaticFile(w, r, filepath)
} }
func (rs sceneRoutes) SceneMarkerPreview(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) SceneMarkerPreview(w http.ResponseWriter, r *http.Request) {
@ -516,12 +501,10 @@ func (rs sceneRoutes) SceneMarkerPreview(w http.ResponseWriter, r *http.Request)
exists, _ := fsutil.FileExists(filepath) exists, _ := fsutil.FileExists(filepath)
if !exists { if !exists {
w.Header().Set("Content-Type", "image/png") w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "no-store") utils.ServeStaticContent(w, r, utils.PendingGenerateResource)
_, _ = w.Write(utils.PendingGenerateResource) } else {
return utils.ServeStaticFile(w, r, filepath)
} }
http.ServeFile(w, r, filepath)
} }
func (rs sceneRoutes) SceneMarkerScreenshot(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) SceneMarkerScreenshot(w http.ResponseWriter, r *http.Request) {
@ -554,12 +537,10 @@ func (rs sceneRoutes) SceneMarkerScreenshot(w http.ResponseWriter, r *http.Reque
exists, _ := fsutil.FileExists(filepath) exists, _ := fsutil.FileExists(filepath)
if !exists { if !exists {
w.Header().Set("Content-Type", "image/png") w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "no-store") utils.ServeStaticContent(w, r, utils.PendingGenerateResource)
_, _ = w.Write(utils.PendingGenerateResource) } else {
return utils.ServeStaticFile(w, r, filepath)
} }
http.ServeFile(w, r, filepath)
} }
// endregion // endregion

View file

@ -67,9 +67,7 @@ func (rs studioRoutes) Image(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := utils.ServeImage(image, w, r); err != nil { utils.ServeImage(w, r, image)
logger.Warnf("error serving studio image: %v", err)
}
} }
func (rs studioRoutes) StudioCtx(next http.Handler) http.Handler { func (rs studioRoutes) StudioCtx(next http.Handler) http.Handler {

View file

@ -67,9 +67,7 @@ func (rs tagRoutes) Image(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := utils.ServeImage(image, w, r); err != nil { utils.ServeImage(w, r, image)
logger.Warnf("error serving tag image: %v", err)
}
} }
func (rs tagRoutes) TagCtx(next http.Handler) http.Handler { func (rs tagRoutes) TagCtx(next http.Handler) http.Handler {

View file

@ -27,17 +27,25 @@ import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/vearutop/statigz" "github.com/vearutop/statigz"
"github.com/go-chi/cors"
"github.com/go-chi/httplog" "github.com/go-chi/httplog"
"github.com/rs/cors"
"github.com/stashapp/stash/internal/api/loaders" "github.com/stashapp/stash/internal/api/loaders"
"github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/plugin" "github.com/stashapp/stash/pkg/plugin"
"github.com/stashapp/stash/pkg/utils"
"github.com/stashapp/stash/ui" "github.com/stashapp/stash/ui"
) )
const (
loginEndpoint = "/login"
logoutEndpoint = "/logout"
gqlEndpoint = "/graphql"
playgroundEndpoint = "/playground"
)
var version string var version string
var buildstamp string var buildstamp string
var githash string var githash string
@ -51,6 +59,7 @@ func Start() error {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(middleware.Heartbeat("/healthz")) r.Use(middleware.Heartbeat("/healthz"))
r.Use(cors.AllowAll().Handler)
r.Use(authenticateHandler()) r.Use(authenticateHandler())
visitedPluginHandler := manager.GetInstance().SessionStore.VisitedPluginHandler() visitedPluginHandler := manager.GetInstance().SessionStore.VisitedPluginHandler()
r.Use(visitedPluginHandler) r.Use(visitedPluginHandler)
@ -67,7 +76,6 @@ func Start() error {
r.Use(SecurityHeadersMiddleware) r.Use(SecurityHeadersMiddleware)
r.Use(middleware.DefaultCompress) r.Use(middleware.DefaultCompress)
r.Use(middleware.StripSlashes) r.Use(middleware.StripSlashes)
r.Use(cors.AllowAll().Handler)
r.Use(BaseURLMiddleware) r.Use(BaseURLMiddleware)
recoverFunc := func(ctx context.Context, err interface{}) error { recoverFunc := func(ctx context.Context, err interface{}) error {
@ -123,6 +131,7 @@ func Start() error {
gqlSrv.SetErrorPresenter(gqlErrorHandler) gqlSrv.SetErrorPresenter(gqlErrorHandler)
gqlHandlerFunc := func(w http.ResponseWriter, r *http.Request) { gqlHandlerFunc := func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
gqlSrv.ServeHTTP(w, r) gqlSrv.ServeHTTP(w, r)
} }
@ -132,14 +141,12 @@ func Start() error {
gqlHandler := visitedPluginHandler(dataloaders.Middleware(http.HandlerFunc(gqlHandlerFunc))) gqlHandler := visitedPluginHandler(dataloaders.Middleware(http.HandlerFunc(gqlHandlerFunc)))
manager.GetInstance().PluginCache.RegisterGQLHandler(gqlHandler) manager.GetInstance().PluginCache.RegisterGQLHandler(gqlHandler)
r.HandleFunc("/graphql", gqlHandlerFunc) r.HandleFunc(gqlEndpoint, gqlHandlerFunc)
r.HandleFunc("/playground", gqlPlayground.Handler("GraphQL playground", "/graphql")) r.HandleFunc(playgroundEndpoint, func(w http.ResponseWriter, r *http.Request) {
setPageSecurityHeaders(w, r)
// session handlers endpoint := getProxyPrefix(r) + gqlEndpoint
r.Post(loginEndPoint, handleLogin(loginUIBox)) gqlPlayground.Handler("GraphQL playground", endpoint)(w, r)
r.Get(logoutEndPoint, handleLogout(loginUIBox)) })
r.Get(loginEndPoint, getLoginHandler(loginUIBox))
r.Mount("/performer", performerRoutes{ r.Mount("/performer", performerRoutes{
txnManager: txnManager, txnManager: txnManager,
@ -174,36 +181,17 @@ func Start() error {
r.HandleFunc("/css", cssHandler(c, pluginCache)) r.HandleFunc("/css", cssHandler(c, pluginCache))
r.HandleFunc("/javascript", javascriptHandler(c, pluginCache)) r.HandleFunc("/javascript", javascriptHandler(c, pluginCache))
r.HandleFunc("/customlocales", func(w http.ResponseWriter, r *http.Request) { r.HandleFunc("/customlocales", customLocalesHandler(c))
w.Header().Set("Content-Type", "application/json")
if c.GetCustomLocalesEnabled() {
// search for custom-locales.json in current directory, then $HOME/.stash
fn := c.GetCustomLocalesPath()
exists, _ := fsutil.FileExists(fn)
if exists {
http.ServeFile(w, r, fn)
return
}
}
_, _ = w.Write([]byte("{}"))
})
r.HandleFunc("/login*", func(w http.ResponseWriter, r *http.Request) { staticLoginUI := statigz.FileServer(loginUIBox.(fs.ReadDirFS))
ext := path.Ext(r.URL.Path)
if ext == ".html" || ext == "" {
prefix := getProxyPrefix(r.Header)
data := getLoginPage(loginUIBox) r.Get(loginEndpoint, handleLogin(loginUIBox))
baseURLIndex := strings.Replace(string(data), "%BASE_URL%", prefix+"/", 2) r.Post(loginEndpoint, handleLoginPost(loginUIBox))
_, _ = w.Write([]byte(baseURLIndex)) r.Get(logoutEndpoint, handleLogout())
} else { r.HandleFunc(loginEndpoint+"/*", func(w http.ResponseWriter, r *http.Request) {
r.URL.Path = strings.Replace(r.URL.Path, loginEndPoint, "", 1) r.URL.Path = strings.TrimPrefix(r.URL.Path, loginEndpoint)
loginRoot, err := fs.Sub(loginUIBox, loginRootDir) w.Header().Set("Cache-Control", "no-cache")
if err != nil { staticLoginUI.ServeHTTP(w, r)
panic(err)
}
http.FileServer(http.FS(loginRoot)).ServeHTTP(w, r)
}
}) })
// Serve static folders // Serve static folders
@ -215,12 +203,10 @@ func Start() error {
} }
customUILocation := c.GetCustomUILocation() customUILocation := c.GetCustomUILocation()
static := statigz.FileServer(uiBox) staticUI := statigz.FileServer(uiBox.(fs.ReadDirFS))
// Serve the web app // Serve the web app
r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
const uiRootDir = "v2.5/build"
ext := path.Ext(r.URL.Path) ext := path.Ext(r.URL.Path)
if customUILocation != "" { if customUILocation != "" {
@ -234,29 +220,29 @@ func Start() error {
if ext == ".html" || ext == "" { if ext == ".html" || ext == "" {
themeColor := c.GetThemeColor() themeColor := c.GetThemeColor()
data, err := uiBox.ReadFile(uiRootDir + "/index.html") data, err := fs.ReadFile(uiBox, "index.html")
if err != nil { if err != nil {
panic(err) panic(err)
} }
indexHtml := string(data)
prefix := getProxyPrefix(r.Header) prefix := getProxyPrefix(r)
baseURLIndex := strings.ReplaceAll(string(data), "%COLOR%", themeColor) indexHtml = strings.ReplaceAll(indexHtml, "%COLOR%", themeColor)
baseURLIndex = strings.ReplaceAll(baseURLIndex, "/%BASE_URL%", prefix) indexHtml = strings.Replace(indexHtml, `<base href="/"`, fmt.Sprintf(`<base href="%s/"`, prefix), 1)
baseURLIndex = strings.Replace(baseURLIndex, "base href=\"/\"", fmt.Sprintf("base href=\"%s\"", prefix+"/"), 1)
_, _ = w.Write([]byte(baseURLIndex)) w.Header().Set("Content-Type", "text/html")
setPageSecurityHeaders(w, r)
utils.ServeStaticContent(w, r, []byte(indexHtml))
} else { } else {
isStatic, _ := path.Match("/static/*/*", r.URL.Path) isStatic, _ := path.Match("/assets/*", r.URL.Path)
if isStatic { if isStatic {
w.Header().Add("Cache-Control", "max-age=604800000") w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
} }
prefix := getProxyPrefix(r.Header) staticUI.ServeHTTP(w, r)
if prefix != "" {
r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix)
}
r.URL.Path = uiRootDir + r.URL.Path
static.ServeHTTP(w, r)
} }
}) })
@ -307,52 +293,34 @@ func Start() error {
return nil return nil
} }
func copyFile(w io.Writer, path string) (time.Time, error) { func copyFile(w io.Writer, path string) error {
f, err := os.Open(path) f, err := os.Open(path)
if err != nil { if err != nil {
return time.Time{}, err return err
} }
defer f.Close() defer f.Close()
info, err := f.Stat()
if err != nil {
return time.Time{}, err
}
_, err = io.Copy(w, f) _, err = io.Copy(w, f)
return info.ModTime(), err return err
} }
func serveFiles(w http.ResponseWriter, r *http.Request, name string, paths []string) { func serveFiles(w http.ResponseWriter, r *http.Request, paths []string) {
buffer := bytes.Buffer{} buffer := bytes.Buffer{}
latestModTime := time.Time{}
for _, path := range paths { for _, path := range paths {
modTime, err := copyFile(&buffer, path) err := copyFile(&buffer, path)
if err != nil { if err != nil {
logger.Errorf("error serving file %s: %v", path, err) logger.Errorf("error serving file %s: %v", path, err)
} else {
if modTime.After(latestModTime) {
latestModTime = modTime
}
buffer.Write([]byte("\n"))
} }
buffer.Write([]byte("\n"))
} }
// Always revalidate with server utils.ServeStaticContent(w, r, buffer.Bytes())
w.Header().Set("Cache-Control", "no-cache")
bufferReader := bytes.NewReader(buffer.Bytes())
http.ServeContent(w, r, name, latestModTime, bufferReader)
} }
func cssHandler(c *config.Instance, pluginCache *plugin.Cache) func(w http.ResponseWriter, r *http.Request) { func cssHandler(c *config.Instance, pluginCache *plugin.Cache) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
// concatenate with plugin css files
w.Header().Set("Content-Type", "text/css")
// add plugin css files first // add plugin css files first
var paths []string var paths []string
@ -369,14 +337,13 @@ func cssHandler(c *config.Instance, pluginCache *plugin.Cache) func(w http.Respo
} }
} }
serveFiles(w, r, "custom.css", paths) w.Header().Set("Content-Type", "text/css")
serveFiles(w, r, paths)
} }
} }
func javascriptHandler(c *config.Instance, pluginCache *plugin.Cache) func(w http.ResponseWriter, r *http.Request) { func javascriptHandler(c *config.Instance, pluginCache *plugin.Cache) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/javascript")
// add plugin javascript files first // add plugin javascript files first
var paths []string var paths []string
@ -393,7 +360,33 @@ func javascriptHandler(c *config.Instance, pluginCache *plugin.Cache) func(w htt
} }
} }
serveFiles(w, r, "custom.js", paths) w.Header().Set("Content-Type", "text/javascript")
serveFiles(w, r, paths)
}
}
func customLocalesHandler(c *config.Instance) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
buffer := bytes.Buffer{}
if c.GetCustomLocalesEnabled() {
// search for custom-locales.json in current directory, then $HOME/.stash
path := c.GetCustomLocalesPath()
exists, _ := fsutil.FileExists(path)
if exists {
err := copyFile(&buffer, path)
if err != nil {
logger.Errorf("error serving file %s: %v", path, err)
}
}
}
if buffer.Len() == 0 {
buffer.Write([]byte("{}"))
}
w.Header().Set("Content-Type", "application/json")
utils.ServeStaticContent(w, r, buffer.Bytes())
} }
} }
@ -480,6 +473,47 @@ func makeTLSConfig(c *config.Instance) (*tls.Config, error) {
return tlsConfig, nil return tlsConfig, nil
} }
func setPageSecurityHeaders(w http.ResponseWriter, r *http.Request) {
c := config.GetInstance()
defaultSrc := "data: 'self' 'unsafe-inline'"
connectSrc := "data: 'self'"
imageSrc := "data: *"
scriptSrc := "'self' 'unsafe-inline' 'unsafe-eval'"
styleSrc := "'self' 'unsafe-inline'"
mediaSrc := "blob: 'self'"
// Workaround Safari bug https://bugs.webkit.org/show_bug.cgi?id=201591
// Allows websocket requests to any origin
connectSrc += " ws: wss:"
// The graphql playground pulls its frontend from a cdn
if r.URL.Path == playgroundEndpoint {
connectSrc += " https://cdn.jsdelivr.net"
scriptSrc += " https://cdn.jsdelivr.net"
styleSrc += " https://cdn.jsdelivr.net"
}
if !c.IsNewSystem() && c.GetHandyKey() != "" {
connectSrc += " https://www.handyfeeling.com"
}
cspDirectives := fmt.Sprintf("default-src %s; connect-src %s; img-src %s; script-src %s; style-src %s; media-src %s;", defaultSrc, connectSrc, imageSrc, scriptSrc, styleSrc, mediaSrc)
cspDirectives += " worker-src blob:; child-src 'none'; object-src 'none'; form-action 'self';"
w.Header().Set("Referrer-Policy", "same-origin")
w.Header().Set("Content-Security-Policy", cspDirectives)
}
func SecurityHeadersMiddleware(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
type contextKey struct { type contextKey struct {
name string name string
} }
@ -488,35 +522,6 @@ var (
BaseURLCtxKey = &contextKey{"BaseURL"} BaseURLCtxKey = &contextKey{"BaseURL"}
) )
func SecurityHeadersMiddleware(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
c := config.GetInstance()
connectableOrigins := "connect-src data: 'self'"
// Workaround Safari bug https://bugs.webkit.org/show_bug.cgi?id=201591
// Allows websocket requests to any origin
connectableOrigins += " ws: wss:"
// The graphql playground pulls its frontend from a cdn
connectableOrigins += " https://cdn.jsdelivr.net "
if !c.IsNewSystem() && c.GetHandyKey() != "" {
connectableOrigins += " https://www.handyfeeling.com"
}
connectableOrigins += "; "
cspDirectives := "default-src data: 'self' 'unsafe-inline';" + connectableOrigins + "img-src data: *; script-src 'self' https://cdn.jsdelivr.net 'unsafe-inline' 'unsafe-eval'; style-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; style-src-elem 'self' https://cdn.jsdelivr.net 'unsafe-inline'; media-src 'self' blob:; child-src 'none'; worker-src blob:; object-src 'none'; form-action 'self'"
w.Header().Set("Referrer-Policy", "same-origin")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-XSS-Protection", "1")
w.Header().Set("Content-Security-Policy", cspDirectives)
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
func BaseURLMiddleware(next http.Handler) http.Handler { func BaseURLMiddleware(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) { fn := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
@ -525,7 +530,7 @@ func BaseURLMiddleware(next http.Handler) http.Handler {
if strings.Compare("https", r.URL.Scheme) == 0 || r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { if strings.Compare("https", r.URL.Scheme) == 0 || r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https" scheme = "https"
} }
prefix := getProxyPrefix(r.Header) prefix := getProxyPrefix(r)
baseURL := scheme + "://" + r.Host + prefix baseURL := scheme + "://" + r.Host + prefix
@ -541,11 +546,6 @@ func BaseURLMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(fn) return http.HandlerFunc(fn)
} }
func getProxyPrefix(headers http.Header) string { func getProxyPrefix(r *http.Request) string {
prefix := "" return strings.TrimRight(r.Header.Get("X-Forwarded-Prefix"), "/")
if headers.Get("X-Forwarded-Prefix") != "" {
prefix = strings.TrimRight(headers.Get("X-Forwarded-Prefix"), "/")
}
return prefix
} }

View file

@ -1,23 +1,25 @@
package api package api
import ( import (
"embed" "bytes"
"errors" "errors"
"fmt" "fmt"
"html/template" "html/template"
"io/fs"
"net/http" "net/http"
"strings"
"github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/session" "github.com/stashapp/stash/pkg/session"
"github.com/stashapp/stash/pkg/utils"
) )
const loginRootDir = "login"
const returnURLParam = "returnURL" const returnURLParam = "returnURL"
func getLoginPage(loginUIBox embed.FS) []byte { func getLoginPage(loginUIBox fs.FS) []byte {
data, err := loginUIBox.ReadFile(loginRootDir + "/login.html") data, err := fs.ReadFile(loginUIBox, "login.html")
if err != nil { if err != nil {
panic(err) panic(err)
} }
@ -29,36 +31,53 @@ type loginTemplateData struct {
Error string Error string
} }
func redirectToLogin(loginUIBox embed.FS, w http.ResponseWriter, returnURL string, loginError string) { func serveLoginPage(loginUIBox fs.FS, w http.ResponseWriter, r *http.Request, returnURL string, loginError string) {
data := getLoginPage(loginUIBox) loginPage := string(getLoginPage(loginUIBox))
templ, err := template.New("Login").Parse(string(data)) prefix := getProxyPrefix(r)
loginPage = strings.ReplaceAll(loginPage, "/%BASE_URL%", prefix)
templ, err := template.New("Login").Parse(loginPage)
if err != nil { if err != nil {
http.Error(w, fmt.Sprintf("error: %s", err), http.StatusInternalServerError) http.Error(w, fmt.Sprintf("error: %s", err), http.StatusInternalServerError)
return return
} }
err = templ.Execute(w, loginTemplateData{URL: returnURL, Error: loginError}) buffer := bytes.Buffer{}
err = templ.Execute(&buffer, loginTemplateData{URL: returnURL, Error: loginError})
if err != nil { if err != nil {
http.Error(w, fmt.Sprintf("error: %s", err), http.StatusInternalServerError) http.Error(w, fmt.Sprintf("error: %s", err), http.StatusInternalServerError)
return
} }
w.Header().Set("Content-Type", "text/html")
setPageSecurityHeaders(w, r)
utils.ServeStaticContent(w, r, buffer.Bytes())
} }
func getLoginHandler(loginUIBox embed.FS) http.HandlerFunc { func handleLogin(loginUIBox fs.FS) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
returnURL := r.URL.Query().Get(returnURLParam)
if !config.GetInstance().HasCredentials() { if !config.GetInstance().HasCredentials() {
http.Redirect(w, r, "/", http.StatusFound) if returnURL != "" {
http.Redirect(w, r, returnURL, http.StatusFound)
} else {
prefix := getProxyPrefix(r)
http.Redirect(w, r, prefix+"/", http.StatusFound)
}
return return
} }
redirectToLogin(loginUIBox, w, r.URL.Query().Get(returnURLParam), "") serveLoginPage(loginUIBox, w, r, returnURL, "")
} }
} }
func handleLogin(loginUIBox embed.FS) http.HandlerFunc { func handleLoginPost(loginUIBox fs.FS) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
url := r.FormValue(returnURLParam) url := r.FormValue(returnURLParam)
if url == "" { if url == "" {
url = "/" url = getProxyPrefix(r) + "/"
} }
err := manager.GetInstance().SessionStore.Login(w, r) err := manager.GetInstance().SessionStore.Login(w, r)
@ -70,8 +89,8 @@ func handleLogin(loginUIBox embed.FS) http.HandlerFunc {
var invalidCredentialsError *session.InvalidCredentialsError var invalidCredentialsError *session.InvalidCredentialsError
if errors.As(err, &invalidCredentialsError) { if errors.As(err, &invalidCredentialsError) {
// redirect back to the login page with an error // serve login page with an error
redirectToLogin(loginUIBox, w, url, "Username or password is invalid") serveLoginPage(loginUIBox, w, r, url, "Username or password is invalid")
return return
} }
@ -84,7 +103,7 @@ func handleLogin(loginUIBox embed.FS) http.HandlerFunc {
} }
} }
func handleLogout(loginUIBox embed.FS) http.HandlerFunc { func handleLogout() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if err := manager.GetInstance().SessionStore.Logout(w, r); err != nil { if err := manager.GetInstance().SessionStore.Logout(w, r); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
@ -92,6 +111,11 @@ func handleLogout(loginUIBox embed.FS) http.HandlerFunc {
} }
// redirect to the login page if credentials are required // redirect to the login page if credentials are required
getLoginHandler(loginUIBox)(w, r) prefix := getProxyPrefix(r)
if config.GetInstance().HasCredentials() {
http.Redirect(w, r, prefix+loginEndpoint, http.StatusFound)
} else {
http.Redirect(w, r, prefix+"/", http.StatusFound)
}
} }
} }

View file

@ -1,19 +0,0 @@
package urlbuilders
import "strconv"
type GalleryURLBuilder struct {
BaseURL string
GalleryID string
}
func NewGalleryURLBuilder(baseURL string, galleryID int) GalleryURLBuilder {
return GalleryURLBuilder{
BaseURL: baseURL,
GalleryID: strconv.Itoa(galleryID),
}
}
func (b GalleryURLBuilder) GetGalleryImageURL(fileIndex int) string {
return b.BaseURL + "/gallery/" + b.GalleryID + "/" + strconv.Itoa(fileIndex)
}

View file

@ -21,9 +21,9 @@ func NewImageURLBuilder(baseURL string, image *models.Image) ImageURLBuilder {
} }
func (b ImageURLBuilder) GetImageURL() string { func (b ImageURLBuilder) GetImageURL() string {
return b.BaseURL + "/image/" + b.ImageID + "/image?" + b.UpdatedAt return b.BaseURL + "/image/" + b.ImageID + "/image?t=" + b.UpdatedAt
} }
func (b ImageURLBuilder) GetThumbnailURL() string { func (b ImageURLBuilder) GetThumbnailURL() string {
return b.BaseURL + "/image/" + b.ImageID + "/thumbnail?" + b.UpdatedAt return b.BaseURL + "/image/" + b.ImageID + "/thumbnail?t=" + b.UpdatedAt
} }

View file

@ -19,10 +19,14 @@ func NewMovieURLBuilder(baseURL string, movie *models.Movie) MovieURLBuilder {
} }
} }
func (b MovieURLBuilder) GetMovieFrontImageURL() string { func (b MovieURLBuilder) GetMovieFrontImageURL(hasImage bool) string {
return b.BaseURL + "/movie/" + b.MovieID + "/frontimage?" + b.UpdatedAt url := b.BaseURL + "/movie/" + b.MovieID + "/frontimage?t=" + b.UpdatedAt
if !hasImage {
url += "&default=true"
}
return url
} }
func (b MovieURLBuilder) GetMovieBackImageURL() string { func (b MovieURLBuilder) GetMovieBackImageURL() string {
return b.BaseURL + "/movie/" + b.MovieID + "/backimage?" + b.UpdatedAt return b.BaseURL + "/movie/" + b.MovieID + "/backimage?t=" + b.UpdatedAt
} }

View file

@ -20,6 +20,10 @@ func NewPerformerURLBuilder(baseURL string, performer *models.Performer) Perform
} }
} }
func (b PerformerURLBuilder) GetPerformerImageURL() string { func (b PerformerURLBuilder) GetPerformerImageURL(hasImage bool) string {
return b.BaseURL + "/performer/" + b.PerformerID + "/image?" + b.UpdatedAt url := b.BaseURL + "/performer/" + b.PerformerID + "/image?t=" + b.UpdatedAt
if !hasImage {
url += "&default=true"
}
return url
} }

View file

@ -4,18 +4,21 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"strconv" "strconv"
"time"
"github.com/stashapp/stash/pkg/models"
) )
type SceneURLBuilder struct { type SceneURLBuilder struct {
BaseURL string BaseURL string
SceneID string SceneID string
UpdatedAt string
} }
func NewSceneURLBuilder(baseURL string, sceneID int) SceneURLBuilder { func NewSceneURLBuilder(baseURL string, scene *models.Scene) SceneURLBuilder {
return SceneURLBuilder{ return SceneURLBuilder{
BaseURL: baseURL, BaseURL: baseURL,
SceneID: strconv.Itoa(sceneID), SceneID: strconv.Itoa(scene.ID),
UpdatedAt: strconv.FormatInt(scene.UpdatedAt.Unix(), 10),
} }
} }
@ -50,26 +53,14 @@ func (b SceneURLBuilder) GetSpriteURL(checksum string) string {
return b.BaseURL + "/scene/" + checksum + "_sprite.jpg" return b.BaseURL + "/scene/" + checksum + "_sprite.jpg"
} }
func (b SceneURLBuilder) GetScreenshotURL(updateTime time.Time) string { func (b SceneURLBuilder) GetScreenshotURL() string {
return b.BaseURL + "/scene/" + b.SceneID + "/screenshot?" + strconv.FormatInt(updateTime.Unix(), 10) return b.BaseURL + "/scene/" + b.SceneID + "/screenshot?t=" + b.UpdatedAt
} }
func (b SceneURLBuilder) GetChaptersVTTURL() string { func (b SceneURLBuilder) GetChaptersVTTURL() string {
return b.BaseURL + "/scene/" + b.SceneID + "/vtt/chapter" return b.BaseURL + "/scene/" + b.SceneID + "/vtt/chapter"
} }
func (b SceneURLBuilder) GetSceneMarkerStreamURL(sceneMarkerID int) string {
return b.BaseURL + "/scene/" + b.SceneID + "/scene_marker/" + strconv.Itoa(sceneMarkerID) + "/stream"
}
func (b SceneURLBuilder) GetSceneMarkerStreamPreviewURL(sceneMarkerID int) string {
return b.BaseURL + "/scene/" + b.SceneID + "/scene_marker/" + strconv.Itoa(sceneMarkerID) + "/preview"
}
func (b SceneURLBuilder) GetSceneMarkerStreamScreenshotURL(sceneMarkerID int) string {
return b.BaseURL + "/scene/" + b.SceneID + "/scene_marker/" + strconv.Itoa(sceneMarkerID) + "/screenshot"
}
func (b SceneURLBuilder) GetFunscriptURL() string { func (b SceneURLBuilder) GetFunscriptURL() string {
return b.BaseURL + "/scene/" + b.SceneID + "/funscript" return b.BaseURL + "/scene/" + b.SceneID + "/funscript"
} }

View file

@ -0,0 +1,33 @@
package urlbuilders
import (
"strconv"
"github.com/stashapp/stash/pkg/models"
)
type SceneMarkerURLBuilder struct {
BaseURL string
SceneID string
MarkerID string
}
func NewSceneMarkerURLBuilder(baseURL string, sceneMarker *models.SceneMarker) SceneMarkerURLBuilder {
return SceneMarkerURLBuilder{
BaseURL: baseURL,
SceneID: strconv.Itoa(int(sceneMarker.SceneID.Int64)),
MarkerID: strconv.Itoa(sceneMarker.ID),
}
}
func (b SceneMarkerURLBuilder) GetStreamURL() string {
return b.BaseURL + "/scene/" + b.SceneID + "/scene_marker/" + b.MarkerID + "/stream"
}
func (b SceneMarkerURLBuilder) GetPreviewURL() string {
return b.BaseURL + "/scene/" + b.SceneID + "/scene_marker/" + b.MarkerID + "/preview"
}
func (b SceneMarkerURLBuilder) GetScreenshotURL() string {
return b.BaseURL + "/scene/" + b.SceneID + "/scene_marker/" + b.MarkerID + "/screenshot"
}

View file

@ -19,6 +19,10 @@ func NewStudioURLBuilder(baseURL string, studio *models.Studio) StudioURLBuilder
} }
} }
func (b StudioURLBuilder) GetStudioImageURL() string { func (b StudioURLBuilder) GetStudioImageURL(hasImage bool) string {
return b.BaseURL + "/studio/" + b.StudioID + "/image?" + b.UpdatedAt url := b.BaseURL + "/studio/" + b.StudioID + "/image?t=" + b.UpdatedAt
if !hasImage {
url += "&default=true"
}
return url
} }

View file

@ -19,6 +19,10 @@ func NewTagURLBuilder(baseURL string, tag *models.Tag) TagURLBuilder {
} }
} }
func (b TagURLBuilder) GetTagImageURL() string { func (b TagURLBuilder) GetTagImageURL(hasImage bool) string {
return b.BaseURL + "/tag/" + b.TagID + "/image?" + b.UpdatedAt url := b.BaseURL + "/tag/" + b.TagID + "/image?t=" + b.UpdatedAt
if !hasImage {
url += "&default=true"
}
return url
} }

View file

@ -80,6 +80,7 @@ func (s *DownloadStore) Serve(hash string, w http.ResponseWriter, r *http.Reques
if f.contentType != "" { if f.contentType != "" {
w.Header().Add("Content-Type", f.contentType) w.Header().Add("Content-Type", f.contentType)
} }
w.Header().Set("Cache-Control", "no-store")
http.ServeFile(w, r, f.path) http.ServeFile(w, r, f.path)
} }

View file

@ -1,28 +0,0 @@
package manager
import (
"embed"
"runtime"
)
const faviconDir = "v2.5/build/"
type FaviconProvider struct {
UIBox embed.FS
}
func (p *FaviconProvider) GetFavicon() []byte {
if runtime.GOOS == "windows" {
faviconPath := faviconDir + "favicon.ico"
ret, _ := p.UIBox.ReadFile(faviconPath)
return ret
}
return p.GetFaviconPng()
}
func (p *FaviconProvider) GetFaviconPng() []byte {
faviconPath := faviconDir + "favicon.png"
ret, _ := p.UIBox.ReadFile(faviconPath)
return ret
}

View file

@ -509,12 +509,8 @@ func (s *Manager) SetBlobStoreOptions() {
} }
func writeStashIcon() { func writeStashIcon() {
p := FaviconProvider{
UIBox: ui.UIBox,
}
iconPath := filepath.Join(instance.Config.GetConfigPath(), "icon.png") iconPath := filepath.Join(instance.Config.GetConfigPath(), "icon.png")
err := os.WriteFile(iconPath, p.GetFaviconPng(), 0644) err := os.WriteFile(iconPath, ui.FaviconProvider.GetFaviconPng(), 0644)
if err != nil { if err != nil {
logger.Errorf("Couldn't write icon file: %s", err.Error()) logger.Errorf("Couldn't write icon file: %s", err.Error())
} }

View file

@ -39,9 +39,15 @@ type SceneServer struct {
} }
func (s *SceneServer) StreamSceneDirect(scene *models.Scene, w http.ResponseWriter, r *http.Request) { func (s *SceneServer) StreamSceneDirect(scene *models.Scene, w http.ResponseWriter, r *http.Request) {
fileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm() // #3526 - return 404 if the scene does not have any files
if scene.Path == "" {
http.Error(w, http.StatusText(404), 404)
return
}
filepath := GetInstance().Paths.Scene.GetStreamPath(scene.Path, scene.GetHash(fileNamingAlgo)) sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
filepath := GetInstance().Paths.Scene.GetStreamPath(scene.Path, sceneHash)
streamRequestCtx := ffmpeg.NewStreamRequestContext(w, r) streamRequestCtx := ffmpeg.NewStreamRequestContext(w, r)
// #2579 - hijacking and closing the connection here causes video playback to fail in Safari // #2579 - hijacking and closing the connection here causes video playback to fail in Safari
@ -69,11 +75,17 @@ func (s *SceneServer) ServeScreenshot(scene *models.Scene, w http.ResponseWriter
if cover == nil { if cover == nil {
// fallback to legacy image if present // fallback to legacy image if present
if scene.Path != "" { if scene.Path != "" {
filepath := GetInstance().Paths.Scene.GetLegacyScreenshotPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())) sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
filepath := GetInstance().Paths.Scene.GetLegacyScreenshotPath(sceneHash)
// fall back to the scene image blob if the file isn't present // fall back to the scene image blob if the file isn't present
screenshotExists, _ := fsutil.FileExists(filepath) screenshotExists, _ := fsutil.FileExists(filepath)
if screenshotExists { if screenshotExists {
if r.URL.Query().Has("t") {
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
http.ServeFile(w, r, filepath) http.ServeFile(w, r, filepath)
return return
} }
@ -83,11 +95,8 @@ func (s *SceneServer) ServeScreenshot(scene *models.Scene, w http.ResponseWriter
// should always be there // should always be there
f, _ := static.Scene.Open(defaultSceneImage) f, _ := static.Scene.Open(defaultSceneImage)
defer f.Close() defer f.Close()
stat, _ := f.Stat() cover, _ = io.ReadAll(f)
http.ServeContent(w, r, "scene.svg", stat.ModTime(), f.(io.ReadSeeker))
} }
if err := utils.ServeImage(cover, w, r); err != nil { utils.ServeImage(w, r, cover)
logger.Warnf("error serving screenshot image: %v", err)
}
} }

View file

@ -20,6 +20,7 @@ import (
"github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/utils"
"github.com/zencoder/go-dash/v3/mpd" "github.com/zencoder/go-dash/v3/mpd"
) )
@ -455,7 +456,7 @@ func serveHLSManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request,
fmt.Fprint(&buf, "#EXT-X-ENDLIST\n") fmt.Fprint(&buf, "#EXT-X-ENDLIST\n")
w.Header().Set("Content-Type", MimeHLS) w.Header().Set("Content-Type", MimeHLS)
http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(buf.Bytes())) utils.ServeStaticContent(w, r, buf.Bytes())
} }
// serveDASHManifest serves a generated DASH manifest. // serveDASHManifest serves a generated DASH manifest.
@ -546,7 +547,7 @@ func serveDASHManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request
_ = m.Write(&buf) _ = m.Write(&buf)
w.Header().Set("Content-Type", MimeDASH) w.Header().Set("Content-Type", MimeDASH)
http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(buf.Bytes())) utils.ServeStaticContent(w, r, buf.Bytes())
} }
func (sm *StreamManager) ServeManifest(w http.ResponseWriter, r *http.Request, streamType *StreamType, vf *file.VideoFile, resolution string) { func (sm *StreamManager) ServeManifest(w http.ResponseWriter, r *http.Request, streamType *StreamType, vf *file.VideoFile, resolution string) {
@ -561,9 +562,7 @@ func (sm *StreamManager) serveWaitingSegment(w http.ResponseWriter, r *http.Requ
if err == nil { if err == nil {
logger.Tracef("[transcode] streaming segment file %s", segment.file) logger.Tracef("[transcode] streaming segment file %s", segment.file)
w.Header().Set("Content-Type", segment.segmentType.MimeType) w.Header().Set("Content-Type", segment.segmentType.MimeType)
// Prevent caching as segments are generated on the fly utils.ServeStaticFile(w, r, segment.path)
w.Header().Add("Cache-Control", "no-cache")
http.ServeFile(w, r, segment.path)
} else if !errors.Is(err, context.Canceled) { } else if !errors.Is(err, context.Canceled) {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }

View file

@ -260,6 +260,7 @@ func (sm *StreamManager) getTranscodeStream(ctx *fsutil.LockContext, options Tra
mimeType := options.StreamType.MimeType mimeType := options.StreamType.MimeType
handler := func(w http.ResponseWriter, r *http.Request) { handler := func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", mimeType) w.Header().Set("Content-Type", mimeType)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)

View file

@ -1,16 +1,13 @@
package file package file
import ( import (
"bytes"
"context" "context"
"errors"
"io" "io"
"io/fs" "io/fs"
"net/http" "net/http"
"strconv" "strconv"
"syscall"
"time" "time"
"github.com/stashapp/stash/pkg/logger"
) )
// ID represents an ID of a file. // ID represents an ID of a file.
@ -119,8 +116,6 @@ func (f *BaseFile) Info(fs FS) (fs.FileInfo, error) {
} }
func (f *BaseFile) Serve(fs FS, w http.ResponseWriter, r *http.Request) error { func (f *BaseFile) Serve(fs FS, w http.ResponseWriter, r *http.Request) error {
w.Header().Add("Cache-Control", "max-age=604800000") // 1 Week
reader, err := f.Open(fs) reader, err := f.Open(fs)
if err != nil { if err != nil {
return err return err
@ -128,23 +123,22 @@ func (f *BaseFile) Serve(fs FS, w http.ResponseWriter, r *http.Request) error {
defer reader.Close() defer reader.Close()
rsc, ok := reader.(io.ReadSeeker) content, ok := reader.(io.ReadSeeker)
if !ok { if !ok {
// fallback to direct copy
data, err := io.ReadAll(reader) data, err := io.ReadAll(reader)
if err != nil { if err != nil {
return err return err
} }
content = bytes.NewReader(data)
k, err := w.Write(data)
if err != nil && !errors.Is(err, syscall.EPIPE) {
logger.Warnf("error serving file (wrote %v bytes out of %v): %v", k, len(data), err)
}
return nil
} }
http.ServeContent(w, r, f.Basename, f.ModTime, rsc) if r.URL.Query().Has("t") {
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
http.ServeContent(w, r, f.Basename, f.ModTime, content)
return nil return nil
} }

View file

@ -342,6 +342,27 @@ func (_m *MovieReaderWriter) HasBackImage(ctx context.Context, movieID int) (boo
return r0, r1 return r0, r1
} }
// HasFrontImage provides a mock function with given fields: ctx, movieID
func (_m *MovieReaderWriter) HasFrontImage(ctx context.Context, movieID int) (bool, error) {
ret := _m.Called(ctx, movieID)
var r0 bool
if rf, ok := ret.Get(0).(func(context.Context, int) bool); ok {
r0 = rf(ctx, movieID)
} else {
r0 = ret.Get(0).(bool)
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
r1 = rf(ctx, movieID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Query provides a mock function with given fields: ctx, movieFilter, findFilter // Query provides a mock function with given fields: ctx, movieFilter, findFilter
func (_m *MovieReaderWriter) Query(ctx context.Context, movieFilter *models.MovieFilterType, findFilter *models.FindFilterType) ([]*models.Movie, int, error) { func (_m *MovieReaderWriter) Query(ctx context.Context, movieFilter *models.MovieFilterType, findFilter *models.FindFilterType) ([]*models.Movie, int, error) {
ret := _m.Called(ctx, movieFilter, findFilter) ret := _m.Called(ctx, movieFilter, findFilter)

View file

@ -397,6 +397,27 @@ func (_m *PerformerReaderWriter) GetTagIDs(ctx context.Context, relatedID int) (
return r0, r1 return r0, r1
} }
// HasImage provides a mock function with given fields: ctx, performerID
func (_m *PerformerReaderWriter) HasImage(ctx context.Context, performerID int) (bool, error) {
ret := _m.Called(ctx, performerID)
var r0 bool
if rf, ok := ret.Get(0).(func(context.Context, int) bool); ok {
r0 = rf(ctx, performerID)
} else {
r0 = ret.Get(0).(bool)
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
r1 = rf(ctx, performerID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Query provides a mock function with given fields: ctx, performerFilter, findFilter // Query provides a mock function with given fields: ctx, performerFilter, findFilter
func (_m *PerformerReaderWriter) Query(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) ([]*models.Performer, int, error) { func (_m *PerformerReaderWriter) Query(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) ([]*models.Performer, int, error) {
ret := _m.Called(ctx, performerFilter, findFilter) ret := _m.Called(ctx, performerFilter, findFilter)

View file

@ -440,6 +440,27 @@ func (_m *TagReaderWriter) GetImage(ctx context.Context, tagID int) ([]byte, err
return r0, r1 return r0, r1
} }
// HasImage provides a mock function with given fields: ctx, tagID
func (_m *TagReaderWriter) HasImage(ctx context.Context, tagID int) (bool, error) {
ret := _m.Called(ctx, tagID)
var r0 bool
if rf, ok := ret.Get(0).(func(context.Context, int) bool); ok {
r0 = rf(ctx, tagID)
} else {
r0 = ret.Get(0).(bool)
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
r1 = rf(ctx, tagID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Merge provides a mock function with given fields: ctx, source, destination // Merge provides a mock function with given fields: ctx, source, destination
func (_m *TagReaderWriter) Merge(ctx context.Context, source []int, destination int) error { func (_m *TagReaderWriter) Merge(ctx context.Context, source []int, destination int) error {
ret := _m.Called(ctx, source, destination) ret := _m.Called(ctx, source, destination)

View file

@ -38,6 +38,7 @@ type MovieReader interface {
Count(ctx context.Context) (int, error) Count(ctx context.Context) (int, error)
Query(ctx context.Context, movieFilter *MovieFilterType, findFilter *FindFilterType) ([]*Movie, int, error) Query(ctx context.Context, movieFilter *MovieFilterType, findFilter *FindFilterType) ([]*Movie, int, error)
GetFrontImage(ctx context.Context, movieID int) ([]byte, error) GetFrontImage(ctx context.Context, movieID int) ([]byte, error)
HasFrontImage(ctx context.Context, movieID int) (bool, error)
GetBackImage(ctx context.Context, movieID int) ([]byte, error) GetBackImage(ctx context.Context, movieID int) ([]byte, error)
HasBackImage(ctx context.Context, movieID int) (bool, error) HasBackImage(ctx context.Context, movieID int) (bool, error)
FindByPerformerID(ctx context.Context, performerID int) ([]*Movie, error) FindByPerformerID(ctx context.Context, performerID int) ([]*Movie, error)

View file

@ -163,6 +163,7 @@ type PerformerReader interface {
QueryCount(ctx context.Context, galleryFilter *PerformerFilterType, findFilter *FindFilterType) (int, error) QueryCount(ctx context.Context, galleryFilter *PerformerFilterType, findFilter *FindFilterType) (int, error)
AliasLoader AliasLoader
GetImage(ctx context.Context, performerID int) ([]byte, error) GetImage(ctx context.Context, performerID int) ([]byte, error)
HasImage(ctx context.Context, performerID int) (bool, error)
StashIDLoader StashIDLoader
TagIDLoader TagIDLoader
} }

View file

@ -63,6 +63,7 @@ type TagReader interface {
QueryForAutoTag(ctx context.Context, words []string) ([]*Tag, error) QueryForAutoTag(ctx context.Context, words []string) ([]*Tag, error)
Query(ctx context.Context, tagFilter *TagFilterType, findFilter *FindFilterType) ([]*Tag, int, error) Query(ctx context.Context, tagFilter *TagFilterType, findFilter *FindFilterType) ([]*Tag, int, error)
GetImage(ctx context.Context, tagID int) ([]byte, error) GetImage(ctx context.Context, tagID int) ([]byte, error)
HasImage(ctx context.Context, tagID int) (bool, error)
GetAliases(ctx context.Context, tagID int) ([]string, error) GetAliases(ctx context.Context, tagID int) ([]string, error)
FindAllAncestors(ctx context.Context, tagID int, excludeIDs []int) ([]*TagPath, error) FindAllAncestors(ctx context.Context, tagID int, excludeIDs []int) ([]*TagPath, error)
FindAllDescendants(ctx context.Context, tagID int, excludeIDs []int) ([]*TagPath, error) FindAllDescendants(ctx context.Context, tagID int, excludeIDs []int) ([]*TagPath, error)

View file

@ -363,6 +363,10 @@ func (qb *movieQueryBuilder) GetFrontImage(ctx context.Context, movieID int) ([]
return qb.GetImage(ctx, movieID, movieFrontImageBlobColumn) return qb.GetImage(ctx, movieID, movieFrontImageBlobColumn)
} }
func (qb *movieQueryBuilder) HasFrontImage(ctx context.Context, movieID int) (bool, error) {
return qb.HasImage(ctx, movieID, movieFrontImageBlobColumn)
}
func (qb *movieQueryBuilder) GetBackImage(ctx context.Context, movieID int) ([]byte, error) { func (qb *movieQueryBuilder) GetBackImage(ctx context.Context, movieID int) ([]byte, error) {
return qb.GetImage(ctx, movieID, movieBackImageBlobColumn) return qb.GetImage(ctx, movieID, movieBackImageBlobColumn)
} }

View file

@ -931,6 +931,10 @@ func (qb *PerformerStore) GetImage(ctx context.Context, performerID int) ([]byte
return qb.blobJoinQueryBuilder.GetImage(ctx, performerID, performerImageBlobColumn) return qb.blobJoinQueryBuilder.GetImage(ctx, performerID, performerImageBlobColumn)
} }
func (qb *PerformerStore) HasImage(ctx context.Context, performerID int) (bool, error) {
return qb.blobJoinQueryBuilder.HasImage(ctx, performerID, performerImageBlobColumn)
}
func (qb *PerformerStore) UpdateImage(ctx context.Context, performerID int, image []byte) error { func (qb *PerformerStore) UpdateImage(ctx context.Context, performerID int, image []byte) error {
return qb.blobJoinQueryBuilder.UpdateImage(ctx, performerID, performerImageBlobColumn, image) return qb.blobJoinQueryBuilder.UpdateImage(ctx, performerID, performerImageBlobColumn, image)
} }

View file

@ -651,6 +651,10 @@ func (qb *tagQueryBuilder) GetImage(ctx context.Context, tagID int) ([]byte, err
return qb.blobJoinQueryBuilder.GetImage(ctx, tagID, tagImageBlobColumn) return qb.blobJoinQueryBuilder.GetImage(ctx, tagID, tagImageBlobColumn)
} }
func (qb *tagQueryBuilder) HasImage(ctx context.Context, tagID int) (bool, error) {
return qb.blobJoinQueryBuilder.HasImage(ctx, tagID, tagImageBlobColumn)
}
func (qb *tagQueryBuilder) UpdateImage(ctx context.Context, tagID int, image []byte) error { func (qb *tagQueryBuilder) UpdateImage(ctx context.Context, tagID int, image []byte) error {
return qb.blobJoinQueryBuilder.UpdateImage(ctx, tagID, tagImageBlobColumn, image) return qb.blobJoinQueryBuilder.UpdateImage(ctx, tagID, tagImageBlobColumn, image)
} }

41
pkg/utils/http.go Normal file
View file

@ -0,0 +1,41 @@
package utils
import (
"bytes"
"net/http"
"time"
"github.com/stashapp/stash/pkg/hash/md5"
)
// Returns an MD5 hash of data, formatted for use as an HTTP ETag header.
// Intended for use with `http.ServeContent`, to respond to conditional requests.
func GenerateETag(data []byte) string {
hash := md5.FromBytes(data)
return `"` + hash + `"`
}
// Serves static content, adding Cache-Control: no-cache and a generated ETag header.
// Responds to conditional requests using the ETag.
func ServeStaticContent(w http.ResponseWriter, r *http.Request, data []byte) {
if r.URL.Query().Has("t") {
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
w.Header().Set("ETag", GenerateETag(data))
http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(data))
}
// Serves static content at filepath, adding Cache-Control: no-cache.
// Responds to conditional requests using the file modtime.
func ServeStaticFile(w http.ResponseWriter, r *http.Request, filepath string) {
if r.URL.Query().Has("t") {
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
http.ServeFile(w, r, filepath)
}

View file

@ -2,16 +2,12 @@ package utils
import ( import (
"context" "context"
"crypto/md5"
"crypto/tls" "crypto/tls"
"encoding/base64" "encoding/base64"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"regexp" "regexp"
"strings"
"syscall"
"time" "time"
) )
@ -110,30 +106,12 @@ func GetBase64StringFromData(data []byte) string {
return base64.StdEncoding.EncodeToString(data) return base64.StdEncoding.EncodeToString(data)
} }
func ServeImage(image []byte, w http.ResponseWriter, r *http.Request) error { func ServeImage(w http.ResponseWriter, r *http.Request, image []byte) {
etag := fmt.Sprintf("%x", md5.Sum(image))
if match := r.Header.Get("If-None-Match"); match != "" {
if strings.Contains(match, etag) {
w.WriteHeader(http.StatusNotModified)
return nil
}
}
contentType := http.DetectContentType(image) contentType := http.DetectContentType(image)
if contentType == "text/xml; charset=utf-8" || contentType == "text/plain; charset=utf-8" { if contentType == "text/xml; charset=utf-8" || contentType == "text/plain; charset=utf-8" {
contentType = "image/svg+xml" contentType = "image/svg+xml"
} }
w.Header().Set("Content-Type", contentType) w.Header().Set("Content-Type", contentType)
w.Header().Add("Etag", etag) ServeStaticContent(w, r, image)
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
_, err := w.Write(image)
// Broken pipe errors are common when serving images and the remote
// connection closes the connection. Filter them out of the error
// messages, as they are benign.
if errors.Is(err, syscall.EPIPE) {
return nil
}
return err
} }

View file

@ -1,7 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<base href="%BASE_URL%"> <base href="/%BASE_URL%/">
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Login</title> <title>Login</title>

View file

@ -1,9 +1,46 @@
package ui package ui
import "embed" import (
"embed"
"io/fs"
"runtime"
)
//go:embed v2.5/build //go:embed v2.5/build
var UIBox embed.FS var uiBox embed.FS
var UIBox fs.FS
//go:embed login //go:embed login
var LoginUIBox embed.FS var loginUIBox embed.FS
var LoginUIBox fs.FS
func init() {
var err error
UIBox, err = fs.Sub(uiBox, "v2.5/build")
if err != nil {
panic(err)
}
LoginUIBox, err = fs.Sub(loginUIBox, "login")
if err != nil {
panic(err)
}
}
type faviconProvider struct{}
var FaviconProvider = faviconProvider{}
func (p *faviconProvider) GetFavicon() []byte {
if runtime.GOOS == "windows" {
ret, _ := fs.ReadFile(UIBox, "favicon.ico")
return ret
}
return p.GetFaviconPng()
}
func (p *faviconProvider) GetFaviconPng() []byte {
ret, _ := fs.ReadFile(UIBox, "favicon.png")
return ret
}

View file

@ -12,9 +12,6 @@
<meta name="theme-color" content="%COLOR%" /> <meta name="theme-color" content="%COLOR%" />
<link rel="manifest" crossorigin="use-credentials" href="manifest.json" /> <link rel="manifest" crossorigin="use-credentials" href="manifest.json" />
<title>Stash</title> <title>Stash</title>
<script>
window.STASH_BASE_URL = "/%BASE_URL%/";
</script>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>

View file

@ -94,6 +94,20 @@ export const App: React.FC = () => {
// use en-GB as default messages if any messages aren't found in the chosen language // use en-GB as default messages if any messages aren't found in the chosen language
const [messages, setMessages] = useState<{}>(); const [messages, setMessages] = useState<{}>();
const [customMessages, setCustomMessages] = useState<{}>();
useEffect(() => {
(async () => {
try {
const res = await fetch(getPlatformURL() + "customlocales");
if (res.ok) {
setCustomMessages(await res.json());
}
} catch (err) {
console.log(err);
}
})();
}, []);
useEffect(() => { useEffect(() => {
const setLocale = async () => { const setLocale = async () => {
@ -106,15 +120,6 @@ export const App: React.FC = () => {
const defaultMessages = (await locales[defaultMessageLanguage]()).default; const defaultMessages = (await locales[defaultMessageLanguage]()).default;
const mergedMessages = cloneDeep(Object.assign({}, defaultMessages)); const mergedMessages = cloneDeep(Object.assign({}, defaultMessages));
const chosenMessages = (await locales[messageLanguage]()).default; const chosenMessages = (await locales[messageLanguage]()).default;
let customMessages = {};
try {
const res = await fetch(getPlatformURL() + "customlocales");
if (res.ok) {
customMessages = await res.json();
}
} catch (err) {
console.log(err);
}
mergeWith( mergeWith(
mergedMessages, mergedMessages,
@ -142,7 +147,7 @@ export const App: React.FC = () => {
}; };
setLocale(); setLocale();
}, [language]); }, [customMessages, language]);
const location = useLocation(); const location = useLocation();
const history = useHistory(); const history = useHistory();

View file

@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { Link } from "react-router-dom";
import { useFindGalleries } from "src/core/StashService"; import { useFindGalleries } from "src/core/StashService";
import Slider from "@ant-design/react-slick"; import Slider from "@ant-design/react-slick";
import { GalleryCard } from "./GalleryCard"; import { GalleryCard } from "./GalleryCard";
@ -26,9 +27,9 @@ export const GalleryRecommendationRow: React.FC<IProps> = (props) => {
className="gallery-recommendations" className="gallery-recommendations"
header={props.header} header={props.header}
link={ link={
<a href={`/galleries?${props.filter.makeQueryParameters()}`}> <Link to={`/galleries?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" /> <FormattedMessage id="view_all" />
</a> </Link>
} }
> >
<Slider <Slider

View file

@ -1,4 +1,5 @@
import React, { Suspense, useState } from "react"; import React, { Suspense, useState } from "react";
import { Link } from "react-router-dom";
import { lazyComponent } from "src/utils/lazyComponent"; import { lazyComponent } from "src/utils/lazyComponent";
const Manual = lazyComponent(() => import("./Manual")); const Manual = lazyComponent(() => import("./Manual"));
@ -48,14 +49,14 @@ export const ManualLink: React.FC<IManualLink> = ({ tab, children }) => {
const { openManual } = React.useContext(ManualStateContext); const { openManual } = React.useContext(ManualStateContext);
return ( return (
<a <Link
href={`/help/${tab}.md`} to={`/help/${tab}.md`}
onClick={(e) => { onClick={(e) => {
openManual(`${tab}.md`); openManual(`${tab}.md`);
e.preventDefault(); e.preventDefault();
}} }}
> >
{children} {children}
</a> </Link>
); );
}; };

View file

@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { Link } from "react-router-dom";
import { useFindImages } from "src/core/StashService"; import { useFindImages } from "src/core/StashService";
import Slider from "@ant-design/react-slick"; import Slider from "@ant-design/react-slick";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
@ -26,9 +27,9 @@ export const ImageRecommendationRow: React.FC<IProps> = (props: IProps) => {
className="images-recommendations" className="images-recommendations"
header={props.header} header={props.header}
link={ link={
<a href={`/images?${props.filter.makeQueryParameters()}`}> <Link to={`/images?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" /> <FormattedMessage id="view_all" />
</a> </Link>
} }
> >
<Slider <Slider

View file

@ -32,6 +32,7 @@ import {
faUser, faUser,
faVideo, faVideo,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { baseURL } from "src/core/createClient";
interface IMenuItem { interface IMenuItem {
name: string; name: string;
@ -256,7 +257,7 @@ export const MainNavbar: React.FC = () => {
return ( return (
<Button <Button
className="minimal logout-button d-flex align-items-center" className="minimal logout-button d-flex align-items-center"
href="/logout" href={`${baseURL}logout`}
title={intl.formatMessage({ id: "actions.logout" })} title={intl.formatMessage({ id: "actions.logout" })}
> >
<Icon icon={faSignOutAlt} /> <Icon icon={faSignOutAlt} />

View file

@ -120,8 +120,10 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
function renderFrontImage() { function renderFrontImage() {
let image = movie.front_image_path; let image = movie.front_image_path;
if (isEditing) { if (isEditing) {
if (frontImage === null) { if (frontImage === null && image) {
image = `${image}&default=true`; const imageURL = new URL(image);
imageURL.searchParams.set("default", "true");
image = imageURL.toString();
} else if (frontImage) { } else if (frontImage) {
image = frontImage; image = frontImage;
} }

View file

@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { Link } from "react-router-dom";
import { useFindMovies } from "src/core/StashService"; import { useFindMovies } from "src/core/StashService";
import Slider from "@ant-design/react-slick"; import Slider from "@ant-design/react-slick";
import { MovieCard } from "./MovieCard"; import { MovieCard } from "./MovieCard";
@ -26,9 +27,9 @@ export const MovieRecommendationRow: React.FC<IProps> = (props: IProps) => {
className="movie-recommendations" className="movie-recommendations"
header={props.header} header={props.header}
link={ link={
<a href={`/movies?${props.filter.makeQueryParameters()}`}> <Link to={`/movies?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" /> <FormattedMessage id="view_all" />
</a> </Link>
} }
> >
<Slider <Slider

View file

@ -60,13 +60,18 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
const [image, setImage] = useState<string | null>(); const [image, setImage] = useState<string | null>();
const [encodingImage, setEncodingImage] = useState<boolean>(false); const [encodingImage, setEncodingImage] = useState<boolean>(false);
// if undefined then get the existing image const activeImage = useMemo(() => {
// if null then get the default (no) image const performerImage = performer.image_path;
// otherwise get the set image if (image === null && performerImage) {
const activeImage = const performerImageURL = new URL(performerImage);
image === undefined performerImageURL.searchParams.set("default", "true");
? performer.image_path ?? "" return performerImageURL.toString();
: image ?? `${performer.image_path}&default=true`; } else if (image) {
return image;
}
return performerImage;
}, [image, performer.image_path]);
const lightboxImages = useMemo( const lightboxImages = useMemo(
() => [{ paths: { thumbnail: activeImage, image: activeImage } }], () => [{ paths: { thumbnail: activeImage, image: activeImage } }],
[activeImage] [activeImage]
@ -141,6 +146,15 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
history.push("/performers"); history.push("/performers");
} }
function renderImage() {
if (activeImage) {
return (
<Button variant="link" onClick={() => showLightbox()}>
<img className="performer" src={activeImage} alt={performer.name} />
</Button>
);
}
}
const renderTabs = () => ( const renderTabs = () => (
<React.Fragment> <React.Fragment>
<Col> <Col>
@ -402,13 +416,7 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
{encodingImage ? ( {encodingImage ? (
<LoadingIndicator message="Encoding image..." /> <LoadingIndicator message="Encoding image..." />
) : ( ) : (
<Button variant="link" onClick={() => showLightbox()}> renderImage()
<img
className="performer"
src={activeImage}
alt={intl.formatMessage({ id: "performer" })}
/>
</Button>
)} )}
</div> </div>
<div className="details-divider d-none d-xl-block"> <div className="details-divider d-none d-xl-block">

View file

@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { Link } from "react-router-dom";
import { useFindPerformers } from "src/core/StashService"; import { useFindPerformers } from "src/core/StashService";
import Slider from "@ant-design/react-slick"; import Slider from "@ant-design/react-slick";
import { PerformerCard } from "./PerformerCard"; import { PerformerCard } from "./PerformerCard";
@ -26,9 +27,9 @@ export const PerformerRecommendationRow: React.FC<IProps> = (props) => {
className="performer-recommendations" className="performer-recommendations"
header={props.header} header={props.header}
link={ link={
<a href={`/performers?${props.filter.makeQueryParameters()}`}> <Link to={`/performers?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" /> <FormattedMessage id="view_all" />
</a> </Link>
} }
> >
<Slider <Slider

View file

@ -97,12 +97,6 @@ export const SceneCard: React.FC<ISceneCardProps> = (
[props.scene] [props.scene]
); );
// studio image is missing if it uses the default
const missingStudioImage =
props.scene.studio?.image_path?.endsWith("?default=true");
const showStudioAsText =
missingStudioImage || (configuration?.interface.showStudioAsText ?? false);
function maybeRenderSceneSpecsOverlay() { function maybeRenderSceneSpecsOverlay() {
let sizeObj = null; let sizeObj = null;
if (file?.size) { if (file?.size) {
@ -146,21 +140,31 @@ export const SceneCard: React.FC<ISceneCardProps> = (
); );
} }
function renderStudioThumbnail() {
const studioImage = props.scene.studio?.image_path;
const studioName = props.scene.studio?.name;
if (configuration?.interface.showStudioAsText || !studioImage) {
return studioName;
}
const studioImageURL = new URL(studioImage);
if (studioImageURL.searchParams.get("default") === "true") {
return studioName;
}
return (
<img className="image-thumbnail" alt={studioName} src={studioImage} />
);
}
function maybeRenderSceneStudioOverlay() { function maybeRenderSceneStudioOverlay() {
if (!props.scene.studio) return; if (!props.scene.studio) return;
return ( return (
<div className="scene-studio-overlay"> <div className="scene-studio-overlay">
<Link to={`/studios/${props.scene.studio.id}`}> <Link to={`/studios/${props.scene.studio.id}`}>
{showStudioAsText ? ( {renderStudioThumbnail()}
props.scene.studio.name
) : (
<img
className="image-thumbnail"
alt={props.scene.studio.name}
src={props.scene.studio.image_path ?? ""}
/>
)}
</Link> </Link>
</div> </div>
); );

View file

@ -1,4 +1,5 @@
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import { Link } from "react-router-dom";
import { useFindScenes } from "src/core/StashService"; import { useFindScenes } from "src/core/StashService";
import Slider from "@ant-design/react-slick"; import Slider from "@ant-design/react-slick";
import { SceneCard } from "./SceneCard"; import { SceneCard } from "./SceneCard";
@ -31,9 +32,9 @@ export const SceneRecommendationRow: React.FC<IProps> = (props) => {
className="scene-recommendations" className="scene-recommendations"
header={props.header} header={props.header}
link={ link={
<a href={`/scenes?${props.filter.makeQueryParameters()}`}> <Link to={`/scenes?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" /> <FormattedMessage id="view_all" />
</a> </Link>
} }
> >
<Slider <Slider

View file

@ -151,8 +151,10 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
function renderImage() { function renderImage() {
let studioImage = studio.image_path; let studioImage = studio.image_path;
if (isEditing) { if (isEditing) {
if (image === null) { if (image === null && studioImage) {
studioImage = `${studioImage}&default=true`; const studioImageURL = new URL(studioImage);
studioImageURL.searchParams.set("default", "true");
studioImage = studioImageURL.toString();
} else if (image) { } else if (image) {
studioImage = image; studioImage = image;
} }

View file

@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { Link } from "react-router-dom";
import { useFindStudios } from "src/core/StashService"; import { useFindStudios } from "src/core/StashService";
import Slider from "@ant-design/react-slick"; import Slider from "@ant-design/react-slick";
import { StudioCard } from "./StudioCard"; import { StudioCard } from "./StudioCard";
@ -26,9 +27,9 @@ export const StudioRecommendationRow: React.FC<IProps> = (props) => {
className="studio-recommendations" className="studio-recommendations"
header={props.header} header={props.header}
link={ link={
<a href={`/studios?${props.filter.makeQueryParameters()}`}> <Link to={`/studios?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" /> <FormattedMessage id="view_all" />
</a> </Link>
} }
> >
<Slider <Slider

View file

@ -196,8 +196,10 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
function renderImage() { function renderImage() {
let tagImage = tag.image_path; let tagImage = tag.image_path;
if (isEditing) { if (isEditing) {
if (image === null) { if (image === null && tagImage) {
tagImage = `${tagImage}&default=true`; const tagImageURL = new URL(tagImage);
tagImageURL.searchParams.set("default", "true");
tagImage = tagImageURL.toString();
} else if (image) { } else if (image) {
tagImage = image; tagImage = image;
} }

View file

@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { Link } from "react-router-dom";
import { useFindTags } from "src/core/StashService"; import { useFindTags } from "src/core/StashService";
import Slider from "@ant-design/react-slick"; import Slider from "@ant-design/react-slick";
import { TagCard } from "./TagCard"; import { TagCard } from "./TagCard";
@ -26,9 +27,9 @@ export const TagRecommendationRow: React.FC<IProps> = (props) => {
className="tag-recommendations" className="tag-recommendations"
header={props.header} header={props.header}
link={ link={
<a href={`/tags?${props.filter.makeQueryParameters()}`}> <Link to={`/tags?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" /> <FormattedMessage id="view_all" />
</a> </Link>
} }
> >
<Slider <Slider

View file

@ -88,14 +88,11 @@ const typePolicies: TypePolicies = {
}, },
}; };
export const getBaseURL = () => { export const baseURL =
const baseURL = window.STASH_BASE_URL; document.querySelector("base")?.getAttribute("href") ?? "/";
if (baseURL === "/%BASE_URL%/") return "/";
return baseURL;
};
export const getPlatformURL = (ws?: boolean) => { export const getPlatformURL = (ws?: boolean) => {
const platformUrl = new URL(window.location.origin + getBaseURL()); const platformUrl = new URL(window.location.origin + baseURL);
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
platformUrl.port = import.meta.env.VITE_APP_PLATFORM_PORT ?? "9999"; platformUrl.port = import.meta.env.VITE_APP_PLATFORM_PORT ?? "9999";
@ -139,10 +136,7 @@ export const createClient = () => {
// handle unauthorized error by redirecting to the login page // handle unauthorized error by redirecting to the login page
if (networkError && (networkError as ServerError).statusCode === 401) { if (networkError && (networkError as ServerError).statusCode === 401) {
// redirect to login page // redirect to login page
const newURL = new URL( const newURL = new URL(`${baseURL}login`, window.location.toString());
`${getBaseURL()}login`,
window.location.toString()
);
newURL.searchParams.append("returnURL", window.location.href); newURL.searchParams.append("returnURL", window.location.href);
window.location.href = newURL.toString(); window.location.href = newURL.toString();
} }

View file

@ -1,5 +1,3 @@
// eslint-disable-next-line no-var
declare var STASH_BASE_URL: string;
declare module "intersection-observer"; declare module "intersection-observer";
declare module "*.md" { declare module "*.md" {

View file

@ -3,14 +3,14 @@ import ReactDOM from "react-dom";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { App } from "./App"; import { App } from "./App";
import { getClient } from "./core/StashService"; import { getClient } from "./core/StashService";
import { getPlatformURL, getBaseURL } from "./core/createClient"; import { baseURL, getPlatformURL } from "./core/createClient";
import "./index.scss"; import "./index.scss";
import * as serviceWorker from "./serviceWorker"; import * as serviceWorker from "./serviceWorker";
ReactDOM.render( ReactDOM.render(
<> <>
<link rel="stylesheet" type="text/css" href={`${getPlatformURL()}css`} /> <link rel="stylesheet" type="text/css" href={`${getPlatformURL()}css`} />
<BrowserRouter basename={getBaseURL()}> <BrowserRouter basename={baseURL}>
<ApolloProvider client={getClient()}> <ApolloProvider client={getClient()}>
<App /> <App />
</ApolloProvider> </ApolloProvider>

21
vendor/github.com/go-chi/cors/LICENSE generated vendored Normal file
View file

@ -0,0 +1,21 @@
Copyright (c) 2014 Olivier Poitrey <rs@dailymotion.com>
Copyright (c) 2016-Present https://github.com/go-chi authors
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

39
vendor/github.com/go-chi/cors/README.md generated vendored Normal file
View file

@ -0,0 +1,39 @@
# CORS net/http middleware
[go-chi/cors](https://github.com/go-chi/cors) is a fork of [github.com/rs/cors](https://github.com/rs/cors) that
provides a `net/http` compatible middleware for performing preflight CORS checks on the server side. These headers
are required for using the browser native [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API).
This middleware is designed to be used as a top-level middleware on the [chi](https://github.com/go-chi/chi) router.
Applying with within a `r.Group()` or using `With()` will not work without routes matching `OPTIONS` added.
## Usage
```go
func main() {
r := chi.NewRouter()
// Basic CORS
// for more ideas, see: https://developer.github.com/v3/#cross-origin-resource-sharing
r.Use(cors.Handler(cors.Options{
// AllowedOrigins: []string{"https://foo.com"}, // Use this to allow specific origin hosts
AllowedOrigins: []string{"https://*", "http://*"},
// AllowOriginFunc: func(r *http.Request, origin string) bool { return true },
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: false,
MaxAge: 300, // Maximum value not ignored by any of major browsers
}))
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("welcome"))
})
http.ListenAndServe(":3000", r)
}
```
## Credits
All credit for the original work of this middleware goes out to [github.com/rs](github.com/rs).

View file

@ -1,23 +1,21 @@
/* // cors package is net/http handler to handle CORS related requests
Package cors is net/http handler to handle CORS related requests // as defined by http://www.w3.org/TR/cors/
as defined by http://www.w3.org/TR/cors/ //
// You can configure it by passing an option struct to cors.New:
You can configure it by passing an option struct to cors.New: //
// c := cors.New(cors.Options{
c := cors.New(cors.Options{ // AllowedOrigins: []string{"foo.com"},
AllowedOrigins: []string{"foo.com"}, // AllowedMethods: []string{"GET", "POST", "DELETE"},
AllowedMethods: []string{"GET", "POST", "DELETE"}, // AllowCredentials: true,
AllowCredentials: true, // })
}) //
// Then insert the handler in the chain:
Then insert the handler in the chain: //
// handler = c.Handler(handler)
handler = c.Handler(handler) //
// See Options documentation for more options.
See Options documentation for more options. //
// The resulting handler is a standard net/http handler.
The resulting handler is a standard net/http handler.
*/
package cors package cors
import ( import (
@ -37,61 +35,77 @@ type Options struct {
// Only one wildcard can be used per origin. // Only one wildcard can be used per origin.
// Default value is ["*"] // Default value is ["*"]
AllowedOrigins []string AllowedOrigins []string
// AllowOriginFunc is a custom function to validate the origin. It take the origin
// AllowOriginFunc is a custom function to validate the origin. It takes the origin
// as argument and returns true if allowed or false otherwise. If this option is // as argument and returns true if allowed or false otherwise. If this option is
// set, the content of AllowedOrigins is ignored. // set, the content of AllowedOrigins is ignored.
AllowOriginFunc func(origin string) bool AllowOriginFunc func(r *http.Request, origin string) bool
// AllowOriginFunc is a custom function to validate the origin. It takes the HTTP Request object and the origin as
// argument and returns true if allowed or false otherwise. If this option is set, the content of `AllowedOrigins`
// and `AllowOriginFunc` is ignored.
AllowOriginRequestFunc func(r *http.Request, origin string) bool
// AllowedMethods is a list of methods the client is allowed to use with // AllowedMethods is a list of methods the client is allowed to use with
// cross-domain requests. Default value is simple methods (HEAD, GET and POST). // cross-domain requests. Default value is simple methods (HEAD, GET and POST).
AllowedMethods []string AllowedMethods []string
// AllowedHeaders is list of non simple headers the client is allowed to use with // AllowedHeaders is list of non simple headers the client is allowed to use with
// cross-domain requests. // cross-domain requests.
// If the special "*" value is present in the list, all headers will be allowed. // If the special "*" value is present in the list, all headers will be allowed.
// Default value is [] but "Origin" is always appended to the list. // Default value is [] but "Origin" is always appended to the list.
AllowedHeaders []string AllowedHeaders []string
// ExposedHeaders indicates which headers are safe to expose to the API of a CORS // ExposedHeaders indicates which headers are safe to expose to the API of a CORS
// API specification // API specification
ExposedHeaders []string ExposedHeaders []string
// MaxAge indicates how long (in seconds) the results of a preflight request
// can be cached
MaxAge int
// AllowCredentials indicates whether the request can include user credentials like // AllowCredentials indicates whether the request can include user credentials like
// cookies, HTTP authentication or client side SSL certificates. // cookies, HTTP authentication or client side SSL certificates.
AllowCredentials bool AllowCredentials bool
// MaxAge indicates how long (in seconds) the results of a preflight request
// can be cached
MaxAge int
// OptionsPassthrough instructs preflight to let other potential next handlers to // OptionsPassthrough instructs preflight to let other potential next handlers to
// process the OPTIONS method. Turn this on if your application handles OPTIONS. // process the OPTIONS method. Turn this on if your application handles OPTIONS.
OptionsPassthrough bool OptionsPassthrough bool
// Debugging flag adds additional output to debug server side CORS issues // Debugging flag adds additional output to debug server side CORS issues
Debug bool Debug bool
} }
// Logger generic interface for logger
type Logger interface {
Printf(string, ...interface{})
}
// Cors http handler // Cors http handler
type Cors struct { type Cors struct {
// Debug logger // Debug logger
Log *log.Logger Log Logger
// Normalized list of plain allowed origins // Normalized list of plain allowed origins
allowedOrigins []string allowedOrigins []string
// List of allowed origins containing wildcards // List of allowed origins containing wildcards
allowedWOrigins []wildcard allowedWOrigins []wildcard
// Optional origin validator function // Optional origin validator function
allowOriginFunc func(origin string) bool allowOriginFunc func(r *http.Request, origin string) bool
// Optional origin validator (with request) function
allowOriginRequestFunc func(r *http.Request, origin string) bool
// Normalized list of allowed headers // Normalized list of allowed headers
allowedHeaders []string allowedHeaders []string
// Normalized list of allowed methods // Normalized list of allowed methods
allowedMethods []string allowedMethods []string
// Normalized list of exposed headers // Normalized list of exposed headers
exposedHeaders []string exposedHeaders []string
maxAge int maxAge int
// Set to true when allowed origins contains a "*" // Set to true when allowed origins contains a "*"
allowedOriginsAll bool allowedOriginsAll bool
// Set to true when allowed headers contains a "*" // Set to true when allowed headers contains a "*"
allowedHeadersAll bool allowedHeadersAll bool
allowCredentials bool allowCredentials bool
optionPassthrough bool optionPassthrough bool
} }
@ -99,14 +113,13 @@ type Cors struct {
// New creates a new Cors handler with the provided options. // New creates a new Cors handler with the provided options.
func New(options Options) *Cors { func New(options Options) *Cors {
c := &Cors{ c := &Cors{
exposedHeaders: convert(options.ExposedHeaders, http.CanonicalHeaderKey), exposedHeaders: convert(options.ExposedHeaders, http.CanonicalHeaderKey),
allowOriginFunc: options.AllowOriginFunc, allowOriginFunc: options.AllowOriginFunc,
allowOriginRequestFunc: options.AllowOriginRequestFunc, allowCredentials: options.AllowCredentials,
allowCredentials: options.AllowCredentials, maxAge: options.MaxAge,
maxAge: options.MaxAge, optionPassthrough: options.OptionsPassthrough,
optionPassthrough: options.OptionsPassthrough,
} }
if options.Debug { if options.Debug && c.Log == nil {
c.Log = log.New(os.Stdout, "[cors] ", log.LstdFlags) c.Log = log.New(os.Stdout, "[cors] ", log.LstdFlags)
} }
@ -116,7 +129,7 @@ func New(options Options) *Cors {
// Allowed Origins // Allowed Origins
if len(options.AllowedOrigins) == 0 { if len(options.AllowedOrigins) == 0 {
if options.AllowOriginFunc == nil && options.AllowOriginRequestFunc == nil { if options.AllowOriginFunc == nil {
// Default is all origins // Default is all origins
c.allowedOriginsAll = true c.allowedOriginsAll = true
} }
@ -145,7 +158,7 @@ func New(options Options) *Cors {
// Allowed Headers // Allowed Headers
if len(options.AllowedHeaders) == 0 { if len(options.AllowedHeaders) == 0 {
// Use sensible defaults // Use sensible defaults
c.allowedHeaders = []string{"Origin", "Accept", "Content-Type", "X-Requested-With"} c.allowedHeaders = []string{"Origin", "Accept", "Content-Type"}
} else { } else {
// Origin is always appended as some browsers will always request for this header at preflight // Origin is always appended as some browsers will always request for this header at preflight
c.allowedHeaders = convert(append(options.AllowedHeaders, "Origin"), http.CanonicalHeaderKey) c.allowedHeaders = convert(append(options.AllowedHeaders, "Origin"), http.CanonicalHeaderKey)
@ -161,7 +174,7 @@ func New(options Options) *Cors {
// Allowed Methods // Allowed Methods
if len(options.AllowedMethods) == 0 { if len(options.AllowedMethods) == 0 {
// Default is spec's "simple" methods // Default is spec's "simple" methods
c.allowedMethods = []string{"GET", "POST", "HEAD"} c.allowedMethods = []string{http.MethodGet, http.MethodPost, http.MethodHead}
} else { } else {
c.allowedMethods = convert(options.AllowedMethods, strings.ToUpper) c.allowedMethods = convert(options.AllowedMethods, strings.ToUpper)
} }
@ -169,17 +182,25 @@ func New(options Options) *Cors {
return c return c
} }
// Default creates a new Cors handler with default options. // Handler creates a new Cors handler with passed options.
func Default() *Cors { func Handler(options Options) func(next http.Handler) http.Handler {
return New(Options{}) c := New(options)
return c.Handler
} }
// AllowAll create a new Cors handler with permissive configuration allowing all // AllowAll create a new Cors handler with permissive configuration allowing all
// origins with all standard methods with any header and credentials. // origins with all standard methods with any header and credentials.
func AllowAll() *Cors { func AllowAll() *Cors {
return New(Options{ return New(Options{
AllowedOrigins: []string{"*"}, AllowedOrigins: []string{"*"},
AllowedMethods: []string{"HEAD", "GET", "POST", "PUT", "PATCH", "DELETE"}, AllowedMethods: []string{
http.MethodHead,
http.MethodGet,
http.MethodPost,
http.MethodPut,
http.MethodPatch,
http.MethodDelete,
},
AllowedHeaders: []string{"*"}, AllowedHeaders: []string{"*"},
AllowCredentials: false, AllowCredentials: false,
}) })
@ -187,7 +208,7 @@ func AllowAll() *Cors {
// Handler apply the CORS specification on the request, and add relevant CORS headers // Handler apply the CORS specification on the request, and add relevant CORS headers
// as necessary. // as necessary.
func (c *Cors) Handler(h http.Handler) http.Handler { func (c *Cors) Handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" { if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
c.logf("Handler: Preflight request") c.logf("Handler: Preflight request")
@ -197,57 +218,25 @@ func (c *Cors) Handler(h http.Handler) http.Handler {
// is authentication middleware ; OPTIONS requests won't carry authentication // is authentication middleware ; OPTIONS requests won't carry authentication
// headers (see #1) // headers (see #1)
if c.optionPassthrough { if c.optionPassthrough {
h.ServeHTTP(w, r) next.ServeHTTP(w, r)
} else { } else {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
} else { } else {
c.logf("Handler: Actual request") c.logf("Handler: Actual request")
c.handleActualRequest(w, r) c.handleActualRequest(w, r)
h.ServeHTTP(w, r) next.ServeHTTP(w, r)
} }
}) })
} }
// HandlerFunc provides Martini compatible handler
func (c *Cors) HandlerFunc(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
c.logf("HandlerFunc: Preflight request")
c.handlePreflight(w, r)
} else {
c.logf("HandlerFunc: Actual request")
c.handleActualRequest(w, r)
}
}
// Negroni compatible interface
func (c *Cors) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
c.logf("ServeHTTP: Preflight request")
c.handlePreflight(w, r)
// Preflight requests are standalone and should stop the chain as some other
// middleware may not handle OPTIONS requests correctly. One typical example
// is authentication middleware ; OPTIONS requests won't carry authentication
// headers (see #1)
if c.optionPassthrough {
next(w, r)
} else {
w.WriteHeader(http.StatusOK)
}
} else {
c.logf("ServeHTTP: Actual request")
c.handleActualRequest(w, r)
next(w, r)
}
}
// handlePreflight handles pre-flight CORS requests // handlePreflight handles pre-flight CORS requests
func (c *Cors) handlePreflight(w http.ResponseWriter, r *http.Request) { func (c *Cors) handlePreflight(w http.ResponseWriter, r *http.Request) {
headers := w.Header() headers := w.Header()
origin := r.Header.Get("Origin") origin := r.Header.Get("Origin")
if r.Method != http.MethodOptions { if r.Method != http.MethodOptions {
c.logf(" Preflight aborted: %s!=OPTIONS", r.Method) c.logf("Preflight aborted: %s!=OPTIONS", r.Method)
return return
} }
// Always set Vary headers // Always set Vary headers
@ -258,22 +247,22 @@ func (c *Cors) handlePreflight(w http.ResponseWriter, r *http.Request) {
headers.Add("Vary", "Access-Control-Request-Headers") headers.Add("Vary", "Access-Control-Request-Headers")
if origin == "" { if origin == "" {
c.logf(" Preflight aborted: empty origin") c.logf("Preflight aborted: empty origin")
return return
} }
if !c.isOriginAllowed(r, origin) { if !c.isOriginAllowed(r, origin) {
c.logf(" Preflight aborted: origin '%s' not allowed", origin) c.logf("Preflight aborted: origin '%s' not allowed", origin)
return return
} }
reqMethod := r.Header.Get("Access-Control-Request-Method") reqMethod := r.Header.Get("Access-Control-Request-Method")
if !c.isMethodAllowed(reqMethod) { if !c.isMethodAllowed(reqMethod) {
c.logf(" Preflight aborted: method '%s' not allowed", reqMethod) c.logf("Preflight aborted: method '%s' not allowed", reqMethod)
return return
} }
reqHeaders := parseHeaderList(r.Header.Get("Access-Control-Request-Headers")) reqHeaders := parseHeaderList(r.Header.Get("Access-Control-Request-Headers"))
if !c.areHeadersAllowed(reqHeaders) { if !c.areHeadersAllowed(reqHeaders) {
c.logf(" Preflight aborted: headers '%v' not allowed", reqHeaders) c.logf("Preflight aborted: headers '%v' not allowed", reqHeaders)
return return
} }
if c.allowedOriginsAll { if c.allowedOriginsAll {
@ -296,7 +285,7 @@ func (c *Cors) handlePreflight(w http.ResponseWriter, r *http.Request) {
if c.maxAge > 0 { if c.maxAge > 0 {
headers.Set("Access-Control-Max-Age", strconv.Itoa(c.maxAge)) headers.Set("Access-Control-Max-Age", strconv.Itoa(c.maxAge))
} }
c.logf(" Preflight response headers: %v", headers) c.logf("Preflight response headers: %v", headers)
} }
// handleActualRequest handles simple cross-origin requests, actual request or redirects // handleActualRequest handles simple cross-origin requests, actual request or redirects
@ -304,18 +293,14 @@ func (c *Cors) handleActualRequest(w http.ResponseWriter, r *http.Request) {
headers := w.Header() headers := w.Header()
origin := r.Header.Get("Origin") origin := r.Header.Get("Origin")
if r.Method == http.MethodOptions {
c.logf(" Actual request no headers added: method == %s", r.Method)
return
}
// Always set Vary, see https://github.com/rs/cors/issues/10 // Always set Vary, see https://github.com/rs/cors/issues/10
headers.Add("Vary", "Origin") headers.Add("Vary", "Origin")
if origin == "" { if origin == "" {
c.logf(" Actual request no headers added: missing origin") c.logf("Actual request no headers added: missing origin")
return return
} }
if !c.isOriginAllowed(r, origin) { if !c.isOriginAllowed(r, origin) {
c.logf(" Actual request no headers added: origin '%s' not allowed", origin) c.logf("Actual request no headers added: origin '%s' not allowed", origin)
return return
} }
@ -324,7 +309,7 @@ func (c *Cors) handleActualRequest(w http.ResponseWriter, r *http.Request) {
// spec doesn't instruct to check the allowed methods for simple cross-origin requests. // spec doesn't instruct to check the allowed methods for simple cross-origin requests.
// We think it's a nice feature to be able to have control on those methods though. // We think it's a nice feature to be able to have control on those methods though.
if !c.isMethodAllowed(r.Method) { if !c.isMethodAllowed(r.Method) {
c.logf(" Actual request no headers added: method '%s' not allowed", r.Method) c.logf("Actual request no headers added: method '%s' not allowed", r.Method)
return return
} }
@ -339,10 +324,10 @@ func (c *Cors) handleActualRequest(w http.ResponseWriter, r *http.Request) {
if c.allowCredentials { if c.allowCredentials {
headers.Set("Access-Control-Allow-Credentials", "true") headers.Set("Access-Control-Allow-Credentials", "true")
} }
c.logf(" Actual response added headers: %v", headers) c.logf("Actual response added headers: %v", headers)
} }
// convenience method. checks if debugging is turned on before printing // convenience method. checks if a logger is set.
func (c *Cors) logf(format string, a ...interface{}) { func (c *Cors) logf(format string, a ...interface{}) {
if c.Log != nil { if c.Log != nil {
c.Log.Printf(format, a...) c.Log.Printf(format, a...)
@ -352,11 +337,8 @@ func (c *Cors) logf(format string, a ...interface{}) {
// isOriginAllowed checks if a given origin is allowed to perform cross-domain requests // isOriginAllowed checks if a given origin is allowed to perform cross-domain requests
// on the endpoint // on the endpoint
func (c *Cors) isOriginAllowed(r *http.Request, origin string) bool { func (c *Cors) isOriginAllowed(r *http.Request, origin string) bool {
if c.allowOriginRequestFunc != nil {
return c.allowOriginRequestFunc(r, origin)
}
if c.allowOriginFunc != nil { if c.allowOriginFunc != nil {
return c.allowOriginFunc(origin) return c.allowOriginFunc(r, origin)
} }
if c.allowedOriginsAll { if c.allowedOriginsAll {
return true return true
@ -376,7 +358,7 @@ func (c *Cors) isOriginAllowed(r *http.Request, origin string) bool {
} }
// isMethodAllowed checks if a given method can be used as part of a cross-domain request // isMethodAllowed checks if a given method can be used as part of a cross-domain request
// on the endpoing // on the endpoint
func (c *Cors) isMethodAllowed(method string) bool { func (c *Cors) isMethodAllowed(method string) bool {
if len(c.allowedMethods) == 0 { if len(c.allowedMethods) == 0 {
// If no method allowed, always return false, even for preflight request // If no method allowed, always return false, even for preflight request
@ -407,6 +389,7 @@ func (c *Cors) areHeadersAllowed(requestedHeaders []string) bool {
for _, h := range c.allowedHeaders { for _, h := range c.allowedHeaders {
if h == header { if h == header {
found = true found = true
break
} }
} }
if !found { if !found {

View file

@ -39,20 +39,19 @@ func parseHeaderList(headerList string) []string {
headers := make([]string, 0, t) headers := make([]string, 0, t)
for i := 0; i < l; i++ { for i := 0; i < l; i++ {
b := headerList[i] b := headerList[i]
switch { if b >= 'a' && b <= 'z' {
case b >= 'a' && b <= 'z':
if upper { if upper {
h = append(h, b-toLower) h = append(h, b-toLower)
} else { } else {
h = append(h, b) h = append(h, b)
} }
case b >= 'A' && b <= 'Z': } else if b >= 'A' && b <= 'Z' {
if !upper { if !upper {
h = append(h, b+toLower) h = append(h, b+toLower)
} else { } else {
h = append(h, b) h = append(h, b)
} }
case b == '-' || b == '_' || (b >= '0' && b <= '9'): } else if b == '-' || b == '_' || b == '.' || (b >= '0' && b <= '9') {
h = append(h, b) h = append(h, b)
} }
@ -64,7 +63,7 @@ func parseHeaderList(headerList string) []string {
upper = true upper = true
} }
} else { } else {
upper = b == '-' || b == '_' upper = b == '-'
} }
} }
return headers return headers

View file

@ -1,8 +0,0 @@
language: go
go:
- 1.9
- "1.10"
- tip
matrix:
allow_failures:
- go: tip

19
vendor/github.com/rs/cors/LICENSE generated vendored
View file

@ -1,19 +0,0 @@
Copyright (c) 2014 Olivier Poitrey <rs@dailymotion.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

115
vendor/github.com/rs/cors/README.md generated vendored
View file

@ -1,115 +0,0 @@
# Go CORS handler [![godoc](http://img.shields.io/badge/godoc-reference-blue.svg?style=flat)](https://godoc.org/github.com/rs/cors) [![license](http://img.shields.io/badge/license-MIT-red.svg?style=flat)](https://raw.githubusercontent.com/rs/cors/master/LICENSE) [![build](https://img.shields.io/travis/rs/cors.svg?style=flat)](https://travis-ci.org/rs/cors) [![Coverage](http://gocover.io/_badge/github.com/rs/cors)](http://gocover.io/github.com/rs/cors)
CORS is a `net/http` handler implementing [Cross Origin Resource Sharing W3 specification](http://www.w3.org/TR/cors/) in Golang.
## Getting Started
After installing Go and setting up your [GOPATH](http://golang.org/doc/code.html#GOPATH), create your first `.go` file. We'll call it `server.go`.
```go
package main
import (
"net/http"
"github.com/rs/cors"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("{\"hello\": \"world\"}"))
})
// cors.Default() setup the middleware with default options being
// all origins accepted with simple methods (GET, POST). See
// documentation below for more options.
handler := cors.Default().Handler(mux)
http.ListenAndServe(":8080", handler)
}
```
Install `cors`:
go get github.com/rs/cors
Then run your server:
go run server.go
The server now runs on `localhost:8080`:
$ curl -D - -H 'Origin: http://foo.com' http://localhost:8080/
HTTP/1.1 200 OK
Access-Control-Allow-Origin: foo.com
Content-Type: application/json
Date: Sat, 25 Oct 2014 03:43:57 GMT
Content-Length: 18
{"hello": "world"}
### Allow * With Credentials Security Protection
This library has been modified to avoid a well known security issue when configured with `AllowedOrigins` to `*` and `AllowCredentials` to `true`. Such setup used to make the library reflects the request `Origin` header value, working around a security protection embedded into the standard that makes clients to refuse such configuration. This behavior has been removed with [#55](https://github.com/rs/cors/issues/55) and [#57](https://github.com/rs/cors/issues/57).
If you depend on this behavior and understand the implications, you can restore it using the `AllowOriginFunc` with `func(origin string) {return true}`.
Please refer to [#55](https://github.com/rs/cors/issues/55) for more information about the security implications.
### More Examples
* `net/http`: [examples/nethttp/server.go](https://github.com/rs/cors/blob/master/examples/nethttp/server.go)
* [Goji](https://goji.io): [examples/goji/server.go](https://github.com/rs/cors/blob/master/examples/goji/server.go)
* [Martini](http://martini.codegangsta.io): [examples/martini/server.go](https://github.com/rs/cors/blob/master/examples/martini/server.go)
* [Negroni](https://github.com/codegangsta/negroni): [examples/negroni/server.go](https://github.com/rs/cors/blob/master/examples/negroni/server.go)
* [Alice](https://github.com/justinas/alice): [examples/alice/server.go](https://github.com/rs/cors/blob/master/examples/alice/server.go)
* [HttpRouter](https://github.com/julienschmidt/httprouter): [examples/httprouter/server.go](https://github.com/rs/cors/blob/master/examples/httprouter/server.go)
* [Gorilla](http://www.gorillatoolkit.org/pkg/mux): [examples/gorilla/server.go](https://github.com/rs/cors/blob/master/examples/gorilla/server.go)
* [Buffalo](https://gobuffalo.io): [examples/buffalo/server.go](https://github.com/rs/cors/blob/master/examples/buffalo/server.go)
* [Gin](https://gin-gonic.github.io/gin): [examples/gin/server.go](https://github.com/rs/cors/blob/master/examples/gin/server.go)
* [Chi](https://github.com/go-chi/chi): [examples/chi/server.go](https://github.com/rs/cors/blob/master/examples/chi/server.go)
## Parameters
Parameters are passed to the middleware thru the `cors.New` method as follow:
```go
c := cors.New(cors.Options{
AllowedOrigins: []string{"http://foo.com", "http://foo.com:8080"},
AllowCredentials: true,
// Enable Debugging for testing, consider disabling in production
Debug: true,
})
// Insert the middleware
handler = c.Handler(handler)
```
* **AllowedOrigins** `[]string`: A list of origins a cross-domain request can be executed from. If the special `*` value is present in the list, all origins will be allowed. An origin may contain a wildcard (`*`) to replace 0 or more characters (i.e.: `http://*.domain.com`). Usage of wildcards implies a small performance penality. Only one wildcard can be used per origin. The default value is `*`.
* **AllowOriginFunc** `func (origin string) bool`: A custom function to validate the origin. It takes the origin as an argument and returns true if allowed, or false otherwise. If this option is set, the content of `AllowedOrigins` is ignored.
* **AllowOriginRequestFunc** `func (r *http.Request origin string) bool`: A custom function to validate the origin. It takes the HTTP Request object and the origin as argument and returns true if allowed or false otherwise. If this option is set, the content of `AllowedOrigins` and `AllowOriginFunc` is ignored
* **AllowedMethods** `[]string`: A list of methods the client is allowed to use with cross-domain requests. Default value is simple methods (`GET` and `POST`).
* **AllowedHeaders** `[]string`: A list of non simple headers the client is allowed to use with cross-domain requests.
* **ExposedHeaders** `[]string`: Indicates which headers are safe to expose to the API of a CORS API specification
* **AllowCredentials** `bool`: Indicates whether the request can include user credentials like cookies, HTTP authentication or client side SSL certificates. The default is `false`.
* **MaxAge** `int`: Indicates how long (in seconds) the results of a preflight request can be cached. The default is `0` which stands for no max age.
* **OptionsPassthrough** `bool`: Instructs preflight to let other potential next handlers to process the `OPTIONS` method. Turn this on if your application handles `OPTIONS`.
* **Debug** `bool`: Debugging flag adds additional output to debug server side CORS issues.
See [API documentation](http://godoc.org/github.com/rs/cors) for more info.
## Benchmarks
BenchmarkWithout 20000000 64.6 ns/op 8 B/op 1 allocs/op
BenchmarkDefault 3000000 469 ns/op 114 B/op 2 allocs/op
BenchmarkAllowedOrigin 3000000 608 ns/op 114 B/op 2 allocs/op
BenchmarkPreflight 20000000 73.2 ns/op 0 B/op 0 allocs/op
BenchmarkPreflightHeader 20000000 73.6 ns/op 0 B/op 0 allocs/op
BenchmarkParseHeaderList 2000000 847 ns/op 184 B/op 6 allocs/op
BenchmarkParse…Single 5000000 290 ns/op 32 B/op 3 allocs/op
BenchmarkParse…Normalized 2000000 776 ns/op 160 B/op 6 allocs/op
## Licenses
All source code is licensed under the [MIT License](https://raw.github.com/rs/cors/master/LICENSE).

6
vendor/modules.txt vendored
View file

@ -154,6 +154,9 @@ github.com/go-chi/chi/middleware
## explicit; go 1.16 ## explicit; go 1.16
github.com/go-chi/chi/v5 github.com/go-chi/chi/v5
github.com/go-chi/chi/v5/middleware github.com/go-chi/chi/v5/middleware
# github.com/go-chi/cors v1.2.1
## explicit; go 1.14
github.com/go-chi/cors
# github.com/go-chi/httplog v0.2.1 # github.com/go-chi/httplog v0.2.1
## explicit; go 1.14 ## explicit; go 1.14
github.com/go-chi/httplog github.com/go-chi/httplog
@ -308,9 +311,6 @@ github.com/robertkrimen/otto/file
github.com/robertkrimen/otto/parser github.com/robertkrimen/otto/parser
github.com/robertkrimen/otto/registry github.com/robertkrimen/otto/registry
github.com/robertkrimen/otto/token github.com/robertkrimen/otto/token
# github.com/rs/cors v1.6.0
## explicit
github.com/rs/cors
# github.com/rs/zerolog v1.26.1 # github.com/rs/zerolog v1.26.1
## explicit; go 1.15 ## explicit; go 1.15
github.com/rs/zerolog github.com/rs/zerolog