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

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

View File

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

View File

@@ -86,33 +86,38 @@ func (r *movieResolver) Synopsis(ctx context.Context, obj *models.Movie) (*strin
}
func (r *movieResolver) FrontImagePath(ctx context.Context, obj *models.Movie) (*string, error) {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
frontimagePath := urlbuilders.NewMovieURLBuilder(baseURL, obj).GetMovieFrontImageURL()
return &frontimagePath, nil
}
func (r *movieResolver) BackImagePath(ctx context.Context, obj *models.Movie) (*string, error) {
// don't return any thing if there is no back image
hasImage := false
var hasImage bool
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
var err error
hasImage, err = r.repository.Movie.HasBackImage(ctx, obj.ID)
if err != nil {
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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,6 @@ import (
"net/http"
"os/exec"
"strconv"
"syscall"
"github.com/go-chi/chi"
"github.com/stashapp/stash/internal/manager"
@@ -19,6 +18,7 @@ import (
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/txn"
"github.com/stashapp/stash/pkg/utils"
)
type ImageFinder interface {
@@ -51,12 +51,10 @@ func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) {
img := r.Context().Value(imageKey).(*models.Image)
filepath := manager.GetInstance().Paths.Generated.GetThumbnailPath(img.Checksum, models.DefaultGthumbWidth)
w.Header().Add("Cache-Control", "max-age=604800000")
// if the thumbnail doesn't exist, encode on the fly
exists, _ := fsutil.FileExists(filepath)
if exists {
http.ServeFile(w, r, filepath)
utils.ServeStaticFile(w, r, filepath)
} else {
const useDefault = true
@@ -88,13 +86,13 @@ func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) {
// write the generated thumbnail to disk if enabled
if manager.GetInstance().Config.IsWriteImageThumbnails() {
logger.Debugf("writing thumbnail to disk: %s", img.Path)
if err := fsutil.WriteFile(filepath, data); err != nil {
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,25 @@
package api
import (
"embed"
"bytes"
"errors"
"fmt"
"html/template"
"io/fs"
"net/http"
"strings"
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/session"
"github.com/stashapp/stash/pkg/utils"
)
const loginRootDir = "login"
const returnURLParam = "returnURL"
func getLoginPage(loginUIBox embed.FS) []byte {
data, err := loginUIBox.ReadFile(loginRootDir + "/login.html")
func getLoginPage(loginUIBox fs.FS) []byte {
data, err := fs.ReadFile(loginUIBox, "login.html")
if err != nil {
panic(err)
}
@@ -29,36 +31,53 @@ type loginTemplateData struct {
Error string
}
func redirectToLogin(loginUIBox embed.FS, w http.ResponseWriter, returnURL string, loginError string) {
data := getLoginPage(loginUIBox)
templ, err := template.New("Login").Parse(string(data))
func serveLoginPage(loginUIBox fs.FS, w http.ResponseWriter, r *http.Request, returnURL string, loginError string) {
loginPage := string(getLoginPage(loginUIBox))
prefix := getProxyPrefix(r)
loginPage = strings.ReplaceAll(loginPage, "/%BASE_URL%", prefix)
templ, err := template.New("Login").Parse(loginPage)
if err != nil {
http.Error(w, fmt.Sprintf("error: %s", err), http.StatusInternalServerError)
return
}
err = templ.Execute(w, loginTemplateData{URL: returnURL, Error: loginError})
buffer := bytes.Buffer{}
err = templ.Execute(&buffer, loginTemplateData{URL: returnURL, Error: loginError})
if err != nil {
http.Error(w, fmt.Sprintf("error: %s", err), http.StatusInternalServerError)
return
}
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)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,18 +4,21 @@ import (
"fmt"
"net/url"
"strconv"
"time"
"github.com/stashapp/stash/pkg/models"
)
type SceneURLBuilder struct {
BaseURL string
SceneID string
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"
}

View File

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

View File

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

View File

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