mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
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:
parent
87abe8c38c
commit
b4b7cf02b6
74 changed files with 808 additions and 782 deletions
|
|
@ -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
2
go.mod
|
|
@ -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
4
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
hasImage, err = r.repository.Movie.HasFrontImage(ctx, obj.ID)
|
||||
return err
|
||||
}); 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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
logger.Errorf("error writing thumbnail for image %s: %v", img.Path, err)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
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"), "/")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
setPageSecurityHeaders(w, r)
|
||||
|
||||
utils.ServeStaticContent(w, r, buffer.Bytes())
|
||||
}
|
||||
|
||||
func getLoginHandler(loginUIBox embed.FS) http.HandlerFunc {
|
||||
func handleLogin(loginUIBox fs.FS) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,18 +4,21 @@ import (
|
|||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
type SceneURLBuilder struct {
|
||||
BaseURL string
|
||||
SceneID string
|
||||
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),
|
||||
BaseURL: baseURL,
|
||||
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"
|
||||
}
|
||||
|
|
|
|||
33
internal/api/urlbuilders/scene_markers.go
Normal file
33
internal/api/urlbuilders/scene_markers.go
Normal 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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
return nil
|
||||
content = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
http.ServeContent(w, r, f.Basename, f.ModTime, rsc)
|
||||
if r.URL.Query().Has("t") {
|
||||
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
http.ServeContent(w, r, f.Basename, f.ModTime, content)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
41
pkg/utils/http.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
43
ui/ui.go
43
ui/ui.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
2
ui/v2.5/src/globals.d.ts
vendored
2
ui/v2.5/src/globals.d.ts
vendored
|
|
@ -1,5 +1,3 @@
|
|||
// eslint-disable-next-line no-var
|
||||
declare var STASH_BASE_URL: string;
|
||||
declare module "intersection-observer";
|
||||
|
||||
declare module "*.md" {
|
||||
|
|
|
|||
|
|
@ -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
21
vendor/github.com/go-chi/cors/LICENSE
generated
vendored
Normal 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
39
vendor/github.com/go-chi/cors/README.md
generated
vendored
Normal 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).
|
||||
189
vendor/github.com/rs/cors/cors.go → vendor/github.com/go-chi/cors/cors.go
generated
vendored
189
vendor/github.com/rs/cors/cors.go → vendor/github.com/go-chi/cors/cors.go
generated
vendored
|
|
@ -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
|
||||
}
|
||||
|
|
@ -99,14 +113,13 @@ type Cors struct {
|
|||
// New creates a new Cors handler with the provided options.
|
||||
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,
|
||||
exposedHeaders: convert(options.ExposedHeaders, http.CanonicalHeaderKey),
|
||||
allowOriginFunc: options.AllowOriginFunc,
|
||||
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,17 +182,25 @@ 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
|
||||
// origins with all standard methods with any header and credentials.
|
||||
func AllowAll() *Cors {
|
||||
return New(Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"HEAD", "GET", "POST", "PUT", "PATCH", "DELETE"},
|
||||
AllowedOrigins: []string{"*"},
|
||||
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,57 +218,25 @@ 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()
|
||||
origin := r.Header.Get("Origin")
|
||||
|
||||
if r.Method != http.MethodOptions {
|
||||
c.logf(" Preflight aborted: %s!=OPTIONS", r.Method)
|
||||
c.logf("Preflight aborted: %s!=OPTIONS", r.Method)
|
||||
return
|
||||
}
|
||||
// Always set Vary headers
|
||||
|
|
@ -258,22 +247,22 @@ func (c *Cors) handlePreflight(w http.ResponseWriter, r *http.Request) {
|
|||
headers.Add("Vary", "Access-Control-Request-Headers")
|
||||
|
||||
if origin == "" {
|
||||
c.logf(" Preflight aborted: empty origin")
|
||||
c.logf("Preflight aborted: empty origin")
|
||||
return
|
||||
}
|
||||
if !c.isOriginAllowed(r, origin) {
|
||||
c.logf(" Preflight aborted: origin '%s' not allowed", origin)
|
||||
c.logf("Preflight aborted: origin '%s' not allowed", origin)
|
||||
return
|
||||
}
|
||||
|
||||
reqMethod := r.Header.Get("Access-Control-Request-Method")
|
||||
if !c.isMethodAllowed(reqMethod) {
|
||||
c.logf(" Preflight aborted: method '%s' not allowed", reqMethod)
|
||||
c.logf("Preflight aborted: method '%s' not allowed", reqMethod)
|
||||
return
|
||||
}
|
||||
reqHeaders := parseHeaderList(r.Header.Get("Access-Control-Request-Headers"))
|
||||
if !c.areHeadersAllowed(reqHeaders) {
|
||||
c.logf(" Preflight aborted: headers '%v' not allowed", reqHeaders)
|
||||
c.logf("Preflight aborted: headers '%v' not allowed", reqHeaders)
|
||||
return
|
||||
}
|
||||
if c.allowedOriginsAll {
|
||||
|
|
@ -296,7 +285,7 @@ func (c *Cors) handlePreflight(w http.ResponseWriter, r *http.Request) {
|
|||
if c.maxAge > 0 {
|
||||
headers.Set("Access-Control-Max-Age", strconv.Itoa(c.maxAge))
|
||||
}
|
||||
c.logf(" Preflight response headers: %v", headers)
|
||||
c.logf("Preflight response headers: %v", headers)
|
||||
}
|
||||
|
||||
// handleActualRequest handles simple cross-origin requests, actual request or redirects
|
||||
|
|
@ -304,18 +293,14 @@ 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 == "" {
|
||||
c.logf(" Actual request no headers added: missing origin")
|
||||
c.logf("Actual request no headers added: missing origin")
|
||||
return
|
||||
}
|
||||
if !c.isOriginAllowed(r, origin) {
|
||||
c.logf(" Actual request no headers added: origin '%s' not allowed", origin)
|
||||
c.logf("Actual request no headers added: origin '%s' not allowed", origin)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -324,7 +309,7 @@ func (c *Cors) handleActualRequest(w http.ResponseWriter, r *http.Request) {
|
|||
// spec doesn't instruct to check the allowed methods for simple cross-origin requests.
|
||||
// We think it's a nice feature to be able to have control on those methods though.
|
||||
if !c.isMethodAllowed(r.Method) {
|
||||
c.logf(" Actual request no headers added: method '%s' not allowed", r.Method)
|
||||
c.logf("Actual request no headers added: method '%s' not allowed", r.Method)
|
||||
|
||||
return
|
||||
}
|
||||
|
|
@ -339,10 +324,10 @@ func (c *Cors) handleActualRequest(w http.ResponseWriter, r *http.Request) {
|
|||
if c.allowCredentials {
|
||||
headers.Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
c.logf(" Actual response added headers: %v", headers)
|
||||
c.logf("Actual response added headers: %v", headers)
|
||||
}
|
||||
|
||||
// convenience method. checks if debugging is turned on before printing
|
||||
// convenience method. checks if a logger is set.
|
||||
func (c *Cors) logf(format string, a ...interface{}) {
|
||||
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 {
|
||||
9
vendor/github.com/rs/cors/utils.go → vendor/github.com/go-chi/cors/utils.go
generated
vendored
9
vendor/github.com/rs/cors/utils.go → vendor/github.com/go-chi/cors/utils.go
generated
vendored
|
|
@ -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
|
||||
8
vendor/github.com/rs/cors/.travis.yml
generated
vendored
8
vendor/github.com/rs/cors/.travis.yml
generated
vendored
|
|
@ -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
19
vendor/github.com/rs/cors/LICENSE
generated
vendored
|
|
@ -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
115
vendor/github.com/rs/cors/README.md
generated
vendored
|
|
@ -1,115 +0,0 @@
|
|||
# Go CORS handler [](https://godoc.org/github.com/rs/cors) [](https://raw.githubusercontent.com/rs/cors/master/LICENSE) [](https://travis-ci.org/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
6
vendor/modules.txt
vendored
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue