mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 04:44:37 +03: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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user