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()
desktop.Start(manager.GetInstance(), &manager.FaviconProvider{UIBox: ui.UIBox})
desktop.Start(manager.GetInstance(), &ui.FaviconProvider)
blockForever()
}

2
go.mod
View file

@ -24,7 +24,6 @@ require (
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8
github.com/remeh/sizedwaitgroup v1.0.0
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/sirupsen/logrus v1.8.1
github.com/spf13/afero v1.8.2 // indirect
@ -48,6 +47,7 @@ require (
require (
github.com/asticode/go-astisub v0.20.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-toast/toast v0.0.0-20190211030409-01e6764cf0a4
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/v5 v5.0.0 h1:DBPx88FjZJH3FsICfDAfIfnb7XxKIYVGG6lOPlhENAg=
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/go.mod h1:JyHOFO9twSfGoTin/RoP25Lx2a9Btq10ug+sgxe0+bo=
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.2.2/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.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=

View file

@ -5,6 +5,7 @@ import (
"net"
"net/http"
"net/url"
"path"
"strings"
"github.com/stashapp/stash/internal/manager"
@ -13,11 +14,6 @@ import (
"github.com/stashapp/stash/pkg/session"
)
const (
loginEndPoint = "/login"
logoutEndPoint = "/logout"
)
const (
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"
@ -30,7 +26,7 @@ const (
func allowUnauthenticated(r *http.Request) bool {
// #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 {
@ -38,38 +34,41 @@ func authenticateHandler() func(http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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
}
userID, err := manager.GetInstance().SessionStore.Authenticate(w, r)
if err != nil {
if errors.Is(err, session.ErrUnauthorized) {
w.WriteHeader(http.StatusInternalServerError)
_, err = w.Write([]byte(err.Error()))
if err != nil {
logger.Error(err)
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// unauthorized error
w.Header().Add("WWW-Authenticate", `FormBased`)
w.Header().Add("WWW-Authenticate", "FormBased")
w.WriteHeader(http.StatusUnauthorized)
return
}
if err := session.CheckAllowPublicWithoutAuth(c, r); err != nil {
var externalAccess session.ExternalAccessError
switch {
case errors.As(err, &externalAccess):
securityActivateTripwireAccessedFromInternetWithoutAuth(c, externalAccess, w)
return
default:
var accessErr session.ExternalAccessError
if errors.As(err, &accessErr) {
session.LogExternalAccessError(accessErr)
err := c.ActivatePublicAccessTripwire(net.IP(accessErr).String())
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)
w.WriteHeader(http.StatusInternalServerError)
return
}
return
}
ctx := r.Context()
@ -77,15 +76,15 @@ func authenticateHandler() func(http.Handler) http.Handler {
if c.HasCredentials() {
// authentication is required
if userID == "" && !allowUnauthenticated(r) {
// authentication was not received, redirect
// if graphql was requested, we just return a forbidden error
if r.URL.Path == "/graphql" {
w.Header().Add("WWW-Authenticate", `FormBased`)
// if graphql or a non-webpage was requested, we just return a forbidden error
ext := path.Ext(r.URL.Path)
if r.URL.Path == gqlEndpoint || (ext != "" && ext != ".html") {
w.Header().Add("WWW-Authenticate", "FormBased")
w.WriteHeader(http.StatusUnauthorized)
return
}
prefix := getProxyPrefix(r.Header)
prefix := getProxyPrefix(r)
// otherwise redirect to the login page
returnURL := url.URL{
@ -95,7 +94,7 @@ func authenticateHandler() func(http.Handler) http.Handler {
q := make(url.Values)
q.Set(returnURLParam, returnURL.String())
u := url.URL{
Path: prefix + "/login",
Path: prefix + loginEndpoint,
RawQuery: q.Encode(),
}
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) {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
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
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)
if err != nil {
hasImage, err = r.repository.Movie.HasFrontImage(ctx, obj.ID)
return err
}
return nil
}); err != nil {
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 {
return nil, nil
}
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
backimagePath := urlbuilders.NewMovieURLBuilder(baseURL, obj).GetMovieBackImageURL()
return &backimagePath, nil
imagePath := urlbuilders.NewMovieURLBuilder(baseURL, obj).GetMovieBackImageURL()
return &imagePath, nil
}
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) {
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)
imagePath := urlbuilders.NewPerformerURLBuilder(baseURL, obj).GetPerformerImageURL()
imagePath := urlbuilders.NewPerformerURLBuilder(baseURL, obj).GetPerformerImageURL(hasImage)
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) {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
config := manager.GetInstance().Config
builder := urlbuilders.NewSceneURLBuilder(baseURL, obj.ID)
screenshotPath := builder.GetScreenshotURL(obj.UpdatedAt)
builder := urlbuilders.NewSceneURLBuilder(baseURL, obj)
screenshotPath := builder.GetScreenshotURL()
previewPath := builder.GetStreamPreviewURL()
streamPath := builder.GetStreamURL(config.GetAPIKey()).String()
webpPath := builder.GetStreamPreviewImageURL()
@ -370,7 +370,7 @@ func (r *sceneResolver) SceneStreams(ctx context.Context, obj *models.Scene) ([]
config := manager.GetInstance().Config
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
builder := urlbuilders.NewSceneURLBuilder(baseURL, obj.ID)
builder := urlbuilders.NewSceneURLBuilder(baseURL, obj)
apiKey := config.GetAPIKey()
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) {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
sceneID := int(obj.SceneID.Int64)
return urlbuilders.NewSceneURLBuilder(baseURL, sceneID).GetSceneMarkerStreamURL(obj.ID), nil
return urlbuilders.NewSceneMarkerURLBuilder(baseURL, obj).GetStreamURL(), nil
}
func (r *sceneMarkerResolver) Preview(ctx context.Context, obj *models.SceneMarker) (string, error) {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
sceneID := int(obj.SceneID.Int64)
return urlbuilders.NewSceneURLBuilder(baseURL, sceneID).GetSceneMarkerStreamPreviewURL(obj.ID), nil
return urlbuilders.NewSceneMarkerURLBuilder(baseURL, obj).GetPreviewURL(), nil
}
func (r *sceneMarkerResolver) Screenshot(ctx context.Context, obj *models.SceneMarker) (string, error) {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
sceneID := int(obj.SceneID.Int64)
return urlbuilders.NewSceneURLBuilder(baseURL, sceneID).GetSceneMarkerStreamScreenshotURL(obj.ID), nil
return urlbuilders.NewSceneMarkerURLBuilder(baseURL, obj).GetScreenshotURL(), nil
}
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) {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
imagePath := urlbuilders.NewStudioURLBuilder(baseURL, obj).GetStudioImageURL()
var hasImage bool
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
var err error
@ -39,11 +36,8 @@ func (r *studioResolver) ImagePath(ctx context.Context, obj *models.Studio) (*st
return nil, err
}
// indicate that image is missing by setting default query param to true
if !hasImage {
imagePath += "?default=true"
}
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
imagePath := urlbuilders.NewStudioURLBuilder(baseURL, obj).GetStudioImageURL(hasImage)
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) {
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)
imagePath := urlbuilders.NewTagURLBuilder(baseURL, obj).GetTagImageURL()
imagePath := urlbuilders.NewTagURLBuilder(baseURL, obj).GetTagImageURL(hasImage)
return &imagePath, nil
}

View file

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

View file

@ -8,7 +8,6 @@ import (
"net/http"
"os/exec"
"strconv"
"syscall"
"github.com/go-chi/chi"
"github.com/stashapp/stash/internal/manager"
@ -19,6 +18,7 @@ import (
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/txn"
"github.com/stashapp/stash/pkg/utils"
)
type ImageFinder interface {
@ -51,12 +51,10 @@ func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) {
img := r.Context().Value(imageKey).(*models.Image)
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
exists, _ := fsutil.FileExists(filepath)
if exists {
http.ServeFile(w, r, filepath)
utils.ServeStaticFile(w, r, filepath)
} else {
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
if manager.GetInstance().Config.IsWriteImageThumbnails() {
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 {
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) {
logger.Errorf("error serving thumbnail (wrote %v bytes out of %v): %v", n, len(data), err)
}
utils.ServeStaticContent(w, r, data)
}
}
@ -131,8 +129,8 @@ func (rs imageRoutes) serveImage(w http.ResponseWriter, r *http.Request, i *mode
// fall back to static image
f, _ := static.Image.Open(defaultImageImage)
defer f.Close()
stat, _ := f.Stat()
http.ServeContent(w, r, "image.svg", stat.ModTime(), f.(io.ReadSeeker))
image, _ := io.ReadAll(f)
utils.ServeImage(w, r, image)
}
// endregion

View file

@ -58,9 +58,7 @@ func (rs movieRoutes) FrontImage(w http.ResponseWriter, r *http.Request) {
image, _ = utils.ProcessBase64Image(models.DefaultMovieImage)
}
if err := utils.ServeImage(image, w, r); err != nil {
logger.Warnf("error serving movie front image: %v", err)
}
utils.ServeImage(w, r, image)
}
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)
}
if err := utils.ServeImage(image, w, r); err != nil {
logger.Warnf("error serving movie back image: %v", err)
}
utils.ServeImage(w, r, image)
}
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())
}
if err := utils.ServeImage(image, w, r); err != nil {
logger.Warnf("error serving performer image: %v", err)
}
utils.ServeImage(w, r, image)
}
func (rs performerRoutes) PerformerCtx(next http.Handler) http.Handler {

View file

@ -88,24 +88,12 @@ func (rs sceneRoutes) Routes() chi.Router {
// region Handlers
func (rs sceneRoutes) StreamDirect(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene)
// #3526 - return 404 if the scene does not have any files
if scene.Path == "" {
w.WriteHeader(http.StatusNotFound)
return
ss := manager.SceneServer{
TxnManager: rs.txnManager,
SceneCoverGetter: rs.sceneFinder,
}
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)
ss.StreamSceneDirect(scene, w, r)
}
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)
sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
filepath := manager.GetInstance().Paths.Scene.GetVideoPreviewPath(sceneHash)
serveFileNoCache(w, r, filepath)
}
// serveFileNoCache serves the provided file, ensuring that the response
// 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)
utils.ServeStaticFile(w, r, filepath)
}
func (rs sceneRoutes) Webp(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene)
sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
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) {
@ -355,7 +337,7 @@ func (rs sceneRoutes) VttChapter(w http.ResponseWriter, r *http.Request) {
vtt := strings.Join(vttLines, "\n")
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) {
@ -366,9 +348,10 @@ func (rs sceneRoutes) VttThumbs(w http.ResponseWriter, r *http.Request) {
} else {
sceneHash = chi.URLParam(r, "sceneHash")
}
w.Header().Set("Content-Type", "text/vtt")
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) {
@ -379,23 +362,24 @@ func (rs sceneRoutes) VttSprite(w http.ResponseWriter, r *http.Request) {
} else {
sceneHash = chi.URLParam(r, "sceneHash")
}
w.Header().Set("Content-Type", "image/jpeg")
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) {
s := r.Context().Value(sceneKey).(*models.Scene)
funscript := video.GetFunscriptPath(s.Path)
serveFileNoCache(w, r, funscript)
filepath := video.GetFunscriptPath(s.Path)
utils.ServeStaticFile(w, r, filepath)
}
func (rs sceneRoutes) InteractiveHeatmap(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene)
sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
w.Header().Set("Content-Type", "image/png")
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) {
@ -434,16 +418,17 @@ func (rs sceneRoutes) Caption(w http.ResponseWriter, r *http.Request, lang strin
return
}
var b bytes.Buffer
err = sub.WriteToWebVTT(&b)
var buf bytes.Buffer
err = sub.WriteToWebVTT(&buf)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/vtt")
w.Header().Add("Cache-Control", "no-cache")
_, _ = b.WriteTo(w)
utils.ServeStaticContent(w, r, buf.Bytes())
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))
http.ServeFile(w, r, filepath)
utils.ServeStaticFile(w, r, filepath)
}
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)
if !exists {
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(utils.PendingGenerateResource)
return
utils.ServeStaticContent(w, r, utils.PendingGenerateResource)
} else {
utils.ServeStaticFile(w, r, filepath)
}
http.ServeFile(w, r, filepath)
}
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)
if !exists {
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(utils.PendingGenerateResource)
return
utils.ServeStaticContent(w, r, utils.PendingGenerateResource)
} else {
utils.ServeStaticFile(w, r, filepath)
}
http.ServeFile(w, r, filepath)
}
// endregion

View file

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

View file

@ -27,17 +27,25 @@ import (
"github.com/gorilla/websocket"
"github.com/vearutop/statigz"
"github.com/go-chi/cors"
"github.com/go-chi/httplog"
"github.com/rs/cors"
"github.com/stashapp/stash/internal/api/loaders"
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/plugin"
"github.com/stashapp/stash/pkg/utils"
"github.com/stashapp/stash/ui"
)
const (
loginEndpoint = "/login"
logoutEndpoint = "/logout"
gqlEndpoint = "/graphql"
playgroundEndpoint = "/playground"
)
var version string
var buildstamp string
var githash string
@ -51,6 +59,7 @@ func Start() error {
r := chi.NewRouter()
r.Use(middleware.Heartbeat("/healthz"))
r.Use(cors.AllowAll().Handler)
r.Use(authenticateHandler())
visitedPluginHandler := manager.GetInstance().SessionStore.VisitedPluginHandler()
r.Use(visitedPluginHandler)
@ -67,7 +76,6 @@ func Start() error {
r.Use(SecurityHeadersMiddleware)
r.Use(middleware.DefaultCompress)
r.Use(middleware.StripSlashes)
r.Use(cors.AllowAll().Handler)
r.Use(BaseURLMiddleware)
recoverFunc := func(ctx context.Context, err interface{}) error {
@ -123,6 +131,7 @@ func Start() error {
gqlSrv.SetErrorPresenter(gqlErrorHandler)
gqlHandlerFunc := func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
gqlSrv.ServeHTTP(w, r)
}
@ -132,14 +141,12 @@ func Start() error {
gqlHandler := visitedPluginHandler(dataloaders.Middleware(http.HandlerFunc(gqlHandlerFunc)))
manager.GetInstance().PluginCache.RegisterGQLHandler(gqlHandler)
r.HandleFunc("/graphql", gqlHandlerFunc)
r.HandleFunc("/playground", gqlPlayground.Handler("GraphQL playground", "/graphql"))
// session handlers
r.Post(loginEndPoint, handleLogin(loginUIBox))
r.Get(logoutEndPoint, handleLogout(loginUIBox))
r.Get(loginEndPoint, getLoginHandler(loginUIBox))
r.HandleFunc(gqlEndpoint, gqlHandlerFunc)
r.HandleFunc(playgroundEndpoint, func(w http.ResponseWriter, r *http.Request) {
setPageSecurityHeaders(w, r)
endpoint := getProxyPrefix(r) + gqlEndpoint
gqlPlayground.Handler("GraphQL playground", endpoint)(w, r)
})
r.Mount("/performer", performerRoutes{
txnManager: txnManager,
@ -174,36 +181,17 @@ func Start() error {
r.HandleFunc("/css", cssHandler(c, pluginCache))
r.HandleFunc("/javascript", javascriptHandler(c, pluginCache))
r.HandleFunc("/customlocales", func(w http.ResponseWriter, r *http.Request) {
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("/customlocales", customLocalesHandler(c))
r.HandleFunc("/login*", func(w http.ResponseWriter, r *http.Request) {
ext := path.Ext(r.URL.Path)
if ext == ".html" || ext == "" {
prefix := getProxyPrefix(r.Header)
staticLoginUI := statigz.FileServer(loginUIBox.(fs.ReadDirFS))
data := getLoginPage(loginUIBox)
baseURLIndex := strings.Replace(string(data), "%BASE_URL%", prefix+"/", 2)
_, _ = w.Write([]byte(baseURLIndex))
} else {
r.URL.Path = strings.Replace(r.URL.Path, loginEndPoint, "", 1)
loginRoot, err := fs.Sub(loginUIBox, loginRootDir)
if err != nil {
panic(err)
}
http.FileServer(http.FS(loginRoot)).ServeHTTP(w, r)
}
r.Get(loginEndpoint, handleLogin(loginUIBox))
r.Post(loginEndpoint, handleLoginPost(loginUIBox))
r.Get(logoutEndpoint, handleLogout())
r.HandleFunc(loginEndpoint+"/*", func(w http.ResponseWriter, r *http.Request) {
r.URL.Path = strings.TrimPrefix(r.URL.Path, loginEndpoint)
w.Header().Set("Cache-Control", "no-cache")
staticLoginUI.ServeHTTP(w, r)
})
// Serve static folders
@ -215,12 +203,10 @@ func Start() error {
}
customUILocation := c.GetCustomUILocation()
static := statigz.FileServer(uiBox)
staticUI := statigz.FileServer(uiBox.(fs.ReadDirFS))
// Serve the web app
r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
const uiRootDir = "v2.5/build"
ext := path.Ext(r.URL.Path)
if customUILocation != "" {
@ -234,29 +220,29 @@ func Start() error {
if ext == ".html" || ext == "" {
themeColor := c.GetThemeColor()
data, err := uiBox.ReadFile(uiRootDir + "/index.html")
data, err := fs.ReadFile(uiBox, "index.html")
if err != nil {
panic(err)
}
indexHtml := string(data)
prefix := getProxyPrefix(r.Header)
baseURLIndex := strings.ReplaceAll(string(data), "%COLOR%", themeColor)
baseURLIndex = strings.ReplaceAll(baseURLIndex, "/%BASE_URL%", prefix)
baseURLIndex = strings.Replace(baseURLIndex, "base href=\"/\"", fmt.Sprintf("base href=\"%s\"", prefix+"/"), 1)
_, _ = w.Write([]byte(baseURLIndex))
prefix := getProxyPrefix(r)
indexHtml = strings.ReplaceAll(indexHtml, "%COLOR%", themeColor)
indexHtml = strings.Replace(indexHtml, `<base href="/"`, fmt.Sprintf(`<base href="%s/"`, prefix), 1)
w.Header().Set("Content-Type", "text/html")
setPageSecurityHeaders(w, r)
utils.ServeStaticContent(w, r, []byte(indexHtml))
} else {
isStatic, _ := path.Match("/static/*/*", r.URL.Path)
isStatic, _ := path.Match("/assets/*", r.URL.Path)
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)
if prefix != "" {
r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix)
}
r.URL.Path = uiRootDir + r.URL.Path
static.ServeHTTP(w, r)
staticUI.ServeHTTP(w, r)
}
})
@ -307,52 +293,34 @@ func Start() error {
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)
if err != nil {
return time.Time{}, err
return err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return time.Time{}, err
}
_, 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{}
latestModTime := time.Time{}
for _, path := range paths {
modTime, err := copyFile(&buffer, path)
err := copyFile(&buffer, path)
if err != nil {
logger.Errorf("error serving file %s: %v", path, err)
} else {
if modTime.After(latestModTime) {
latestModTime = modTime
}
buffer.Write([]byte("\n"))
}
}
// Always revalidate with server
w.Header().Set("Cache-Control", "no-cache")
bufferReader := bytes.NewReader(buffer.Bytes())
http.ServeContent(w, r, name, latestModTime, bufferReader)
utils.ServeStaticContent(w, r, buffer.Bytes())
}
func cssHandler(c *config.Instance, pluginCache *plugin.Cache) 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
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) {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/javascript")
// add plugin javascript files first
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
}
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 {
name string
}
@ -488,35 +522,6 @@ var (
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 {
fn := func(w http.ResponseWriter, r *http.Request) {
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" {
scheme = "https"
}
prefix := getProxyPrefix(r.Header)
prefix := getProxyPrefix(r)
baseURL := scheme + "://" + r.Host + prefix
@ -541,11 +546,6 @@ func BaseURLMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(fn)
}
func getProxyPrefix(headers http.Header) string {
prefix := ""
if headers.Get("X-Forwarded-Prefix") != "" {
prefix = strings.TrimRight(headers.Get("X-Forwarded-Prefix"), "/")
}
return prefix
func getProxyPrefix(r *http.Request) string {
return strings.TrimRight(r.Header.Get("X-Forwarded-Prefix"), "/")
}

View file

@ -1,23 +1,25 @@
package api
import (
"embed"
"bytes"
"errors"
"fmt"
"html/template"
"io/fs"
"net/http"
"strings"
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/session"
"github.com/stashapp/stash/pkg/utils"
)
const loginRootDir = "login"
const returnURLParam = "returnURL"
func getLoginPage(loginUIBox embed.FS) []byte {
data, err := loginUIBox.ReadFile(loginRootDir + "/login.html")
func getLoginPage(loginUIBox fs.FS) []byte {
data, err := fs.ReadFile(loginUIBox, "login.html")
if err != nil {
panic(err)
}
@ -29,36 +31,53 @@ type loginTemplateData struct {
Error string
}
func redirectToLogin(loginUIBox embed.FS, w http.ResponseWriter, returnURL string, loginError string) {
data := getLoginPage(loginUIBox)
templ, err := template.New("Login").Parse(string(data))
func serveLoginPage(loginUIBox fs.FS, w http.ResponseWriter, r *http.Request, returnURL string, loginError string) {
loginPage := string(getLoginPage(loginUIBox))
prefix := getProxyPrefix(r)
loginPage = strings.ReplaceAll(loginPage, "/%BASE_URL%", prefix)
templ, err := template.New("Login").Parse(loginPage)
if err != nil {
http.Error(w, fmt.Sprintf("error: %s", err), http.StatusInternalServerError)
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 {
http.Error(w, fmt.Sprintf("error: %s", err), http.StatusInternalServerError)
}
return
}
func getLoginHandler(loginUIBox embed.FS) http.HandlerFunc {
w.Header().Set("Content-Type", "text/html")
setPageSecurityHeaders(w, r)
utils.ServeStaticContent(w, r, buffer.Bytes())
}
func handleLogin(loginUIBox fs.FS) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
returnURL := r.URL.Query().Get(returnURLParam)
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
}
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) {
url := r.FormValue(returnURLParam)
if url == "" {
url = "/"
url = getProxyPrefix(r) + "/"
}
err := manager.GetInstance().SessionStore.Login(w, r)
@ -70,8 +89,8 @@ func handleLogin(loginUIBox embed.FS) http.HandlerFunc {
var invalidCredentialsError *session.InvalidCredentialsError
if errors.As(err, &invalidCredentialsError) {
// redirect back to the login page with an error
redirectToLogin(loginUIBox, w, url, "Username or password is invalid")
// serve login page with an error
serveLoginPage(loginUIBox, w, r, url, "Username or password is invalid")
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) {
if err := manager.GetInstance().SessionStore.Logout(w, r); err != nil {
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
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 {
return b.BaseURL + "/image/" + b.ImageID + "/image?" + b.UpdatedAt
return b.BaseURL + "/image/" + b.ImageID + "/image?t=" + b.UpdatedAt
}
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 {
return b.BaseURL + "/movie/" + b.MovieID + "/frontimage?" + b.UpdatedAt
func (b MovieURLBuilder) GetMovieFrontImageURL(hasImage bool) string {
url := b.BaseURL + "/movie/" + b.MovieID + "/frontimage?t=" + b.UpdatedAt
if !hasImage {
url += "&default=true"
}
return url
}
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 {
return b.BaseURL + "/performer/" + b.PerformerID + "/image?" + b.UpdatedAt
func (b PerformerURLBuilder) GetPerformerImageURL(hasImage bool) string {
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"
"net/url"
"strconv"
"time"
"github.com/stashapp/stash/pkg/models"
)
type SceneURLBuilder struct {
BaseURL string
SceneID string
UpdatedAt string
}
func NewSceneURLBuilder(baseURL string, sceneID int) SceneURLBuilder {
func NewSceneURLBuilder(baseURL string, scene *models.Scene) SceneURLBuilder {
return SceneURLBuilder{
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"
}
func (b SceneURLBuilder) GetScreenshotURL(updateTime time.Time) string {
return b.BaseURL + "/scene/" + b.SceneID + "/screenshot?" + strconv.FormatInt(updateTime.Unix(), 10)
func (b SceneURLBuilder) GetScreenshotURL() string {
return b.BaseURL + "/scene/" + b.SceneID + "/screenshot?t=" + b.UpdatedAt
}
func (b SceneURLBuilder) GetChaptersVTTURL() string {
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 {
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 {
return b.BaseURL + "/studio/" + b.StudioID + "/image?" + b.UpdatedAt
func (b StudioURLBuilder) GetStudioImageURL(hasImage bool) string {
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 {
return b.BaseURL + "/tag/" + b.TagID + "/image?" + b.UpdatedAt
func (b TagURLBuilder) GetTagImageURL(hasImage bool) string {
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 != "" {
w.Header().Add("Content-Type", f.contentType)
}
w.Header().Set("Cache-Control", "no-store")
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() {
p := FaviconProvider{
UIBox: ui.UIBox,
}
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 {
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) {
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)
// #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 {
// fallback to legacy image if present
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
screenshotExists, _ := fsutil.FileExists(filepath)
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)
return
}
@ -83,11 +95,8 @@ func (s *SceneServer) ServeScreenshot(scene *models.Scene, w http.ResponseWriter
// should always be there
f, _ := static.Scene.Open(defaultSceneImage)
defer f.Close()
stat, _ := f.Stat()
http.ServeContent(w, r, "scene.svg", stat.ModTime(), f.(io.ReadSeeker))
cover, _ = io.ReadAll(f)
}
if err := utils.ServeImage(cover, w, r); err != nil {
logger.Warnf("error serving screenshot image: %v", err)
}
utils.ServeImage(w, r, cover)
}

View file

@ -20,6 +20,7 @@ import (
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/utils"
"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")
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.
@ -546,7 +547,7 @@ func serveDASHManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request
_ = m.Write(&buf)
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) {
@ -561,9 +562,7 @@ func (sm *StreamManager) serveWaitingSegment(w http.ResponseWriter, r *http.Requ
if err == nil {
logger.Tracef("[transcode] streaming segment file %s", segment.file)
w.Header().Set("Content-Type", segment.segmentType.MimeType)
// Prevent caching as segments are generated on the fly
w.Header().Add("Cache-Control", "no-cache")
http.ServeFile(w, r, segment.path)
utils.ServeStaticFile(w, r, segment.path)
} else if !errors.Is(err, context.Canceled) {
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
handler := func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", mimeType)
w.WriteHeader(http.StatusOK)

View file

@ -1,16 +1,13 @@
package file
import (
"bytes"
"context"
"errors"
"io"
"io/fs"
"net/http"
"strconv"
"syscall"
"time"
"github.com/stashapp/stash/pkg/logger"
)
// 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 {
w.Header().Add("Cache-Control", "max-age=604800000") // 1 Week
reader, err := f.Open(fs)
if err != nil {
return err
@ -128,23 +123,22 @@ func (f *BaseFile) Serve(fs FS, w http.ResponseWriter, r *http.Request) error {
defer reader.Close()
rsc, ok := reader.(io.ReadSeeker)
content, ok := reader.(io.ReadSeeker)
if !ok {
// fallback to direct copy
data, err := io.ReadAll(reader)
if err != nil {
return err
}
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)
content = bytes.NewReader(data)
}
return nil
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)
http.ServeContent(w, r, f.Basename, f.ModTime, rsc)
return nil
}

View file

@ -342,6 +342,27 @@ func (_m *MovieReaderWriter) HasBackImage(ctx context.Context, movieID int) (boo
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
func (_m *MovieReaderWriter) Query(ctx context.Context, movieFilter *models.MovieFilterType, findFilter *models.FindFilterType) ([]*models.Movie, int, error) {
ret := _m.Called(ctx, movieFilter, findFilter)

View file

@ -397,6 +397,27 @@ func (_m *PerformerReaderWriter) GetTagIDs(ctx context.Context, relatedID int) (
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
func (_m *PerformerReaderWriter) Query(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) ([]*models.Performer, int, error) {
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
}
// 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
func (_m *TagReaderWriter) Merge(ctx context.Context, source []int, destination int) error {
ret := _m.Called(ctx, source, destination)

View file

@ -38,6 +38,7 @@ type MovieReader interface {
Count(ctx context.Context) (int, error)
Query(ctx context.Context, movieFilter *MovieFilterType, findFilter *FindFilterType) ([]*Movie, int, 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)
HasBackImage(ctx context.Context, movieID int) (bool, 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)
AliasLoader
GetImage(ctx context.Context, performerID int) ([]byte, error)
HasImage(ctx context.Context, performerID int) (bool, error)
StashIDLoader
TagIDLoader
}

View file

@ -63,6 +63,7 @@ type TagReader interface {
QueryForAutoTag(ctx context.Context, words []string) ([]*Tag, error)
Query(ctx context.Context, tagFilter *TagFilterType, findFilter *FindFilterType) ([]*Tag, int, 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)
FindAllAncestors(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)
}
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) {
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)
}
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 {
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)
}
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 {
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 (
"context"
"crypto/md5"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"syscall"
"time"
)
@ -110,30 +106,12 @@ func GetBase64StringFromData(data []byte) string {
return base64.StdEncoding.EncodeToString(data)
}
func ServeImage(image []byte, w http.ResponseWriter, r *http.Request) error {
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
}
}
func ServeImage(w http.ResponseWriter, r *http.Request, image []byte) {
contentType := http.DetectContentType(image)
if contentType == "text/xml; charset=utf-8" || contentType == "text/plain; charset=utf-8" {
contentType = "image/svg+xml"
}
w.Header().Set("Content-Type", contentType)
w.Header().Add("Etag", etag)
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
ServeStaticContent(w, r, image)
}

View file

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

View file

@ -1,9 +1,46 @@
package ui
import "embed"
import (
"embed"
"io/fs"
"runtime"
)
//go:embed v2.5/build
var UIBox embed.FS
var uiBox embed.FS
var UIBox fs.FS
//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%" />
<link rel="manifest" crossorigin="use-credentials" href="manifest.json" />
<title>Stash</title>
<script>
window.STASH_BASE_URL = "/%BASE_URL%/";
</script>
</head>
<body>
<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
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(() => {
const setLocale = async () => {
@ -106,15 +120,6 @@ export const App: React.FC = () => {
const defaultMessages = (await locales[defaultMessageLanguage]()).default;
const mergedMessages = cloneDeep(Object.assign({}, defaultMessages));
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(
mergedMessages,
@ -142,7 +147,7 @@ export const App: React.FC = () => {
};
setLocale();
}, [language]);
}, [customMessages, language]);
const location = useLocation();
const history = useHistory();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -97,12 +97,6 @@ export const SceneCard: React.FC<ISceneCardProps> = (
[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() {
let sizeObj = null;
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() {
if (!props.scene.studio) return;
return (
<div className="scene-studio-overlay">
<Link to={`/studios/${props.scene.studio.id}`}>
{showStudioAsText ? (
props.scene.studio.name
) : (
<img
className="image-thumbnail"
alt={props.scene.studio.name}
src={props.scene.studio.image_path ?? ""}
/>
)}
{renderStudioThumbnail()}
</Link>
</div>
);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -88,14 +88,11 @@ const typePolicies: TypePolicies = {
},
};
export const getBaseURL = () => {
const baseURL = window.STASH_BASE_URL;
if (baseURL === "/%BASE_URL%/") return "/";
return baseURL;
};
export const baseURL =
document.querySelector("base")?.getAttribute("href") ?? "/";
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) {
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
if (networkError && (networkError as ServerError).statusCode === 401) {
// redirect to login page
const newURL = new URL(
`${getBaseURL()}login`,
window.location.toString()
);
const newURL = new URL(`${baseURL}login`, window.location.toString());
newURL.searchParams.append("returnURL", window.location.href);
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 "*.md" {

View file

@ -3,14 +3,14 @@ import ReactDOM from "react-dom";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import { getClient } from "./core/StashService";
import { getPlatformURL, getBaseURL } from "./core/createClient";
import { baseURL, getPlatformURL } from "./core/createClient";
import "./index.scss";
import * as serviceWorker from "./serviceWorker";
ReactDOM.render(
<>
<link rel="stylesheet" type="text/css" href={`${getPlatformURL()}css`} />
<BrowserRouter basename={getBaseURL()}>
<BrowserRouter basename={baseURL}>
<ApolloProvider client={getClient()}>
<App />
</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 @@
/*
Package cors is net/http handler to handle CORS related requests
as defined by http://www.w3.org/TR/cors/
You can configure it by passing an option struct to cors.New:
c := cors.New(cors.Options{
AllowedOrigins: []string{"foo.com"},
AllowedMethods: []string{"GET", "POST", "DELETE"},
AllowCredentials: true,
})
Then insert the handler in the chain:
handler = c.Handler(handler)
See Options documentation for more options.
The resulting handler is a standard net/http handler.
*/
// cors package is net/http handler to handle CORS related requests
// as defined by http://www.w3.org/TR/cors/
//
// You can configure it by passing an option struct to cors.New:
//
// c := cors.New(cors.Options{
// AllowedOrigins: []string{"foo.com"},
// AllowedMethods: []string{"GET", "POST", "DELETE"},
// AllowCredentials: true,
// })
//
// Then insert the handler in the chain:
//
// handler = c.Handler(handler)
//
// See Options documentation for more options.
//
// The resulting handler is a standard net/http handler.
package cors
import (
@ -37,61 +35,77 @@ type Options struct {
// Only one wildcard can be used per origin.
// Default value is ["*"]
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
// set, the content of AllowedOrigins is ignored.
AllowOriginFunc func(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
AllowOriginFunc func(r *http.Request, origin string) bool
// 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).
AllowedMethods []string
// AllowedHeaders is list of non simple headers the client is allowed to use with
// cross-domain requests.
// 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.
AllowedHeaders []string
// ExposedHeaders indicates which headers are safe to expose to the API of a CORS
// API specification
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
// cookies, HTTP authentication or client side SSL certificates.
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
// process the OPTIONS method. Turn this on if your application handles OPTIONS.
OptionsPassthrough bool
// Debugging flag adds additional output to debug server side CORS issues
Debug bool
}
// Logger generic interface for logger
type Logger interface {
Printf(string, ...interface{})
}
// Cors http handler
type Cors struct {
// Debug logger
Log *log.Logger
Log Logger
// Normalized list of plain allowed origins
allowedOrigins []string
// List of allowed origins containing wildcards
allowedWOrigins []wildcard
// Optional origin validator function
allowOriginFunc func(origin string) bool
// Optional origin validator (with request) function
allowOriginRequestFunc func(r *http.Request, origin string) bool
allowOriginFunc func(r *http.Request, origin string) bool
// Normalized list of allowed headers
allowedHeaders []string
// Normalized list of allowed methods
allowedMethods []string
// Normalized list of exposed headers
exposedHeaders []string
maxAge int
// Set to true when allowed origins contains a "*"
allowedOriginsAll bool
// Set to true when allowed headers contains a "*"
allowedHeadersAll bool
allowCredentials bool
optionPassthrough bool
}
@ -101,12 +115,11 @@ func New(options Options) *Cors {
c := &Cors{
exposedHeaders: convert(options.ExposedHeaders, http.CanonicalHeaderKey),
allowOriginFunc: options.AllowOriginFunc,
allowOriginRequestFunc: options.AllowOriginRequestFunc,
allowCredentials: options.AllowCredentials,
maxAge: options.MaxAge,
optionPassthrough: options.OptionsPassthrough,
}
if options.Debug {
if options.Debug && c.Log == nil {
c.Log = log.New(os.Stdout, "[cors] ", log.LstdFlags)
}
@ -116,7 +129,7 @@ func New(options Options) *Cors {
// Allowed Origins
if len(options.AllowedOrigins) == 0 {
if options.AllowOriginFunc == nil && options.AllowOriginRequestFunc == nil {
if options.AllowOriginFunc == nil {
// Default is all origins
c.allowedOriginsAll = true
}
@ -145,7 +158,7 @@ func New(options Options) *Cors {
// Allowed Headers
if len(options.AllowedHeaders) == 0 {
// Use sensible defaults
c.allowedHeaders = []string{"Origin", "Accept", "Content-Type", "X-Requested-With"}
c.allowedHeaders = []string{"Origin", "Accept", "Content-Type"}
} else {
// Origin is always appended as some browsers will always request for this header at preflight
c.allowedHeaders = convert(append(options.AllowedHeaders, "Origin"), http.CanonicalHeaderKey)
@ -161,7 +174,7 @@ func New(options Options) *Cors {
// Allowed Methods
if len(options.AllowedMethods) == 0 {
// Default is spec's "simple" methods
c.allowedMethods = []string{"GET", "POST", "HEAD"}
c.allowedMethods = []string{http.MethodGet, http.MethodPost, http.MethodHead}
} else {
c.allowedMethods = convert(options.AllowedMethods, strings.ToUpper)
}
@ -169,9 +182,10 @@ func New(options Options) *Cors {
return c
}
// Default creates a new Cors handler with default options.
func Default() *Cors {
return New(Options{})
// Handler creates a new Cors handler with passed options.
func Handler(options Options) func(next http.Handler) http.Handler {
c := New(options)
return c.Handler
}
// AllowAll create a new Cors handler with permissive configuration allowing all
@ -179,7 +193,14 @@ func Default() *Cors {
func AllowAll() *Cors {
return New(Options{
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{"*"},
AllowCredentials: false,
})
@ -187,7 +208,7 @@ func AllowAll() *Cors {
// Handler apply the CORS specification on the request, and add relevant CORS headers
// 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) {
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
c.logf("Handler: Preflight request")
@ -197,50 +218,18 @@ func (c *Cors) Handler(h http.Handler) http.Handler {
// is authentication middleware ; OPTIONS requests won't carry authentication
// headers (see #1)
if c.optionPassthrough {
h.ServeHTTP(w, r)
next.ServeHTTP(w, r)
} else {
w.WriteHeader(http.StatusOK)
}
} else {
c.logf("Handler: Actual request")
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
func (c *Cors) handlePreflight(w http.ResponseWriter, r *http.Request) {
headers := w.Header()
@ -304,10 +293,6 @@ func (c *Cors) handleActualRequest(w http.ResponseWriter, r *http.Request) {
headers := w.Header()
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
headers.Add("Vary", "Origin")
if origin == "" {
@ -342,7 +327,7 @@ func (c *Cors) handleActualRequest(w http.ResponseWriter, r *http.Request) {
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{}) {
if c.Log != nil {
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
// on the endpoint
func (c *Cors) isOriginAllowed(r *http.Request, origin string) bool {
if c.allowOriginRequestFunc != nil {
return c.allowOriginRequestFunc(r, origin)
}
if c.allowOriginFunc != nil {
return c.allowOriginFunc(origin)
return c.allowOriginFunc(r, origin)
}
if c.allowedOriginsAll {
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
// on the endpoing
// on the endpoint
func (c *Cors) isMethodAllowed(method string) bool {
if len(c.allowedMethods) == 0 {
// 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 {
if h == header {
found = true
break
}
}
if !found {

View file

@ -39,20 +39,19 @@ func parseHeaderList(headerList string) []string {
headers := make([]string, 0, t)
for i := 0; i < l; i++ {
b := headerList[i]
switch {
case b >= 'a' && b <= 'z':
if b >= 'a' && b <= 'z' {
if upper {
h = append(h, b-toLower)
} else {
h = append(h, b)
}
case b >= 'A' && b <= 'Z':
} else if b >= 'A' && b <= 'Z' {
if !upper {
h = append(h, b+toLower)
} else {
h = append(h, b)
}
case b == '-' || b == '_' || (b >= '0' && b <= '9'):
} else if b == '-' || b == '_' || b == '.' || (b >= '0' && b <= '9') {
h = append(h, b)
}
@ -64,7 +63,7 @@ func parseHeaderList(headerList string) []string {
upper = true
}
} else {
upper = b == '-' || b == '_'
upper = b == '-'
}
}
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
github.com/go-chi/chi/v5
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
## explicit; go 1.14
github.com/go-chi/httplog
@ -308,9 +311,6 @@ github.com/robertkrimen/otto/file
github.com/robertkrimen/otto/parser
github.com/robertkrimen/otto/registry
github.com/robertkrimen/otto/token
# github.com/rs/cors v1.6.0
## explicit
github.com/rs/cors
# github.com/rs/zerolog v1.26.1
## explicit; go 1.15
github.com/rs/zerolog