mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 04:44:37 +03:00
Plugin assets, external scripts and CSP overrides (#4260)
* Add assets for plugins * Move plugin javascript and css into separate endpoints * Allow loading external scripts * Add csp overrides * Only include enabled plugins * Move URLMap to utils * Use URLMap for assets * Add documentation
This commit is contained in:
@@ -13,4 +13,5 @@ const (
|
||||
tagKey
|
||||
downloadKey
|
||||
imageKey
|
||||
pluginKey
|
||||
)
|
||||
|
||||
@@ -84,6 +84,9 @@ func (r *Resolver) Tag() TagResolver {
|
||||
func (r *Resolver) SavedFilter() SavedFilterResolver {
|
||||
return &savedFilterResolver{r}
|
||||
}
|
||||
func (r *Resolver) Plugin() PluginResolver {
|
||||
return &pluginResolver{r}
|
||||
}
|
||||
func (r *Resolver) ConfigResult() ConfigResultResolver {
|
||||
return &configResultResolver{r}
|
||||
}
|
||||
@@ -102,6 +105,7 @@ type studioResolver struct{ *Resolver }
|
||||
type movieResolver struct{ *Resolver }
|
||||
type tagResolver struct{ *Resolver }
|
||||
type savedFilterResolver struct{ *Resolver }
|
||||
type pluginResolver struct{ *Resolver }
|
||||
type configResultResolver struct{ *Resolver }
|
||||
|
||||
func (r *Resolver) withTxn(ctx context.Context, fn func(ctx context.Context) error) error {
|
||||
|
||||
57
internal/api/resolver_model_plugin.go
Normal file
57
internal/api/resolver_model_plugin.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/stashapp/stash/pkg/plugin"
|
||||
)
|
||||
|
||||
type pluginURLBuilder struct {
|
||||
BaseURL string
|
||||
Plugin *plugin.Plugin
|
||||
}
|
||||
|
||||
func (b pluginURLBuilder) javascript() []string {
|
||||
ui := b.Plugin.UI
|
||||
if len(ui.Javascript) == 0 && len(ui.ExternalScript) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var ret []string
|
||||
|
||||
ret = append(ret, ui.ExternalScript...)
|
||||
ret = append(ret, b.BaseURL+"/plugin/"+b.Plugin.ID+"/javascript")
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func (b pluginURLBuilder) css() []string {
|
||||
ui := b.Plugin.UI
|
||||
if len(ui.CSS) == 0 && len(ui.ExternalCSS) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var ret []string
|
||||
|
||||
ret = append(ret, b.Plugin.UI.ExternalCSS...)
|
||||
ret = append(ret, b.BaseURL+"/plugin/"+b.Plugin.ID+"/css")
|
||||
return ret
|
||||
}
|
||||
|
||||
func (b *pluginURLBuilder) paths() *PluginPaths {
|
||||
return &PluginPaths{
|
||||
Javascript: b.javascript(),
|
||||
CSS: b.css(),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *pluginResolver) Paths(ctx context.Context, obj *plugin.Plugin) (*PluginPaths, error) {
|
||||
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
|
||||
|
||||
b := pluginURLBuilder{
|
||||
BaseURL: baseURL,
|
||||
Plugin: obj,
|
||||
}
|
||||
|
||||
return b.paths(), nil
|
||||
}
|
||||
@@ -5,15 +5,14 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/stashapp/stash/internal/manager/config"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
type customRoutes struct {
|
||||
servedFolders config.URLMap
|
||||
servedFolders utils.URLMap
|
||||
}
|
||||
|
||||
func getCustomRoutes(servedFolders config.URLMap) chi.Router {
|
||||
func getCustomRoutes(servedFolders utils.URLMap) chi.Router {
|
||||
return customRoutes{servedFolders: servedFolders}.Routes()
|
||||
}
|
||||
|
||||
|
||||
107
internal/api/routes_plugin.go
Normal file
107
internal/api/routes_plugin.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/stashapp/stash/pkg/plugin"
|
||||
)
|
||||
|
||||
type pluginRoutes struct {
|
||||
pluginCache *plugin.Cache
|
||||
}
|
||||
|
||||
func getPluginRoutes(pluginCache *plugin.Cache) chi.Router {
|
||||
return pluginRoutes{
|
||||
pluginCache: pluginCache,
|
||||
}.Routes()
|
||||
}
|
||||
|
||||
func (rs pluginRoutes) Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Route("/{pluginId}", func(r chi.Router) {
|
||||
r.Use(rs.PluginCtx)
|
||||
r.Get("/assets/*", rs.Assets)
|
||||
r.Get("/javascript", rs.Javascript)
|
||||
r.Get("/css", rs.CSS)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (rs pluginRoutes) Assets(w http.ResponseWriter, r *http.Request) {
|
||||
p := r.Context().Value(pluginKey).(*plugin.Plugin)
|
||||
|
||||
if !p.Enabled {
|
||||
http.Error(w, "plugin disabled", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
prefix := "/plugin/" + chi.URLParam(r, "pluginId") + "/assets"
|
||||
|
||||
r.URL.Path = strings.Replace(r.URL.Path, prefix, "", 1)
|
||||
|
||||
// http.FileServer redirects to / if the path ends with index.html
|
||||
r.URL.Path = strings.TrimSuffix(r.URL.Path, "/index.html")
|
||||
|
||||
pluginDir := filepath.Dir(p.ConfigPath)
|
||||
|
||||
// map the path to the applicable filesystem location
|
||||
var dir string
|
||||
r.URL.Path, dir = p.UI.Assets.GetFilesystemLocation(r.URL.Path)
|
||||
if dir == "" {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
dir = filepath.Join(pluginDir, filepath.FromSlash(dir))
|
||||
|
||||
// ensure directory is still within the plugin directory
|
||||
if !strings.HasPrefix(dir, pluginDir) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.FileServer(http.Dir(dir)).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (rs pluginRoutes) Javascript(w http.ResponseWriter, r *http.Request) {
|
||||
p := r.Context().Value(pluginKey).(*plugin.Plugin)
|
||||
|
||||
if !p.Enabled {
|
||||
http.Error(w, "plugin disabled", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/javascript")
|
||||
serveFiles(w, r, p.UI.Javascript)
|
||||
}
|
||||
|
||||
func (rs pluginRoutes) CSS(w http.ResponseWriter, r *http.Request) {
|
||||
p := r.Context().Value(pluginKey).(*plugin.Plugin)
|
||||
|
||||
if !p.Enabled {
|
||||
http.Error(w, "plugin disabled", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/css")
|
||||
serveFiles(w, r, p.UI.CSS)
|
||||
}
|
||||
|
||||
func (rs pluginRoutes) PluginCtx(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p := rs.pluginCache.GetPlugin(chi.URLParam(r, "pluginId"))
|
||||
if p == nil {
|
||||
http.Error(w, http.StatusText(404), 404)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), pluginKey, p)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
@@ -138,7 +138,7 @@ func Start() error {
|
||||
|
||||
r.HandleFunc(gqlEndpoint, gqlHandlerFunc)
|
||||
r.HandleFunc(playgroundEndpoint, func(w http.ResponseWriter, r *http.Request) {
|
||||
setPageSecurityHeaders(w, r)
|
||||
setPageSecurityHeaders(w, r, pluginCache.ListPlugins())
|
||||
endpoint := getProxyPrefix(r) + gqlEndpoint
|
||||
gqlPlayground.Handler("GraphQL playground", endpoint)(w, r)
|
||||
})
|
||||
@@ -150,9 +150,10 @@ func Start() error {
|
||||
r.Mount("/movie", getMovieRoutes(repo))
|
||||
r.Mount("/tag", getTagRoutes(repo))
|
||||
r.Mount("/downloads", getDownloadsRoutes())
|
||||
r.Mount("/plugin", getPluginRoutes(pluginCache))
|
||||
|
||||
r.HandleFunc("/css", cssHandler(c, pluginCache))
|
||||
r.HandleFunc("/javascript", javascriptHandler(c, pluginCache))
|
||||
r.HandleFunc("/css", cssHandler(c))
|
||||
r.HandleFunc("/javascript", javascriptHandler(c))
|
||||
r.HandleFunc("/customlocales", customLocalesHandler(c))
|
||||
|
||||
staticLoginUI := statigz.FileServer(loginUIBox.(fs.ReadDirFS))
|
||||
@@ -201,7 +202,7 @@ func Start() error {
|
||||
indexHtml = strings.Replace(indexHtml, `<base href="/"`, fmt.Sprintf(`<base href="%s/"`, prefix), 1)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
setPageSecurityHeaders(w, r)
|
||||
setPageSecurityHeaders(w, r, pluginCache.ListPlugins())
|
||||
|
||||
utils.ServeStaticContent(w, r, []byte(indexHtml))
|
||||
} else {
|
||||
@@ -289,19 +290,10 @@ func serveFiles(w http.ResponseWriter, r *http.Request, paths []string) {
|
||||
utils.ServeStaticContent(w, r, buffer.Bytes())
|
||||
}
|
||||
|
||||
func cssHandler(c *config.Instance, pluginCache *plugin.Cache) func(w http.ResponseWriter, r *http.Request) {
|
||||
func cssHandler(c *config.Instance) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// add plugin css files first
|
||||
var paths []string
|
||||
|
||||
for _, p := range pluginCache.ListPlugins() {
|
||||
if !p.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
paths = append(paths, p.UI.CSS...)
|
||||
}
|
||||
|
||||
if c.GetCSSEnabled() {
|
||||
// search for custom.css in current directory, then $HOME/.stash
|
||||
fn := c.GetCSSPath()
|
||||
@@ -316,19 +308,10 @@ func cssHandler(c *config.Instance, pluginCache *plugin.Cache) func(w http.Respo
|
||||
}
|
||||
}
|
||||
|
||||
func javascriptHandler(c *config.Instance, pluginCache *plugin.Cache) func(w http.ResponseWriter, r *http.Request) {
|
||||
func javascriptHandler(c *config.Instance) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// add plugin javascript files first
|
||||
var paths []string
|
||||
|
||||
for _, p := range pluginCache.ListPlugins() {
|
||||
if !p.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
paths = append(paths, p.UI.Javascript...)
|
||||
}
|
||||
|
||||
if c.GetJavascriptEnabled() {
|
||||
// search for custom.js in current directory, then $HOME/.stash
|
||||
fn := c.GetJavascriptPath()
|
||||
@@ -408,31 +391,75 @@ func makeTLSConfig(c *config.Instance) (*tls.Config, error) {
|
||||
return tlsConfig, nil
|
||||
}
|
||||
|
||||
func setPageSecurityHeaders(w http.ResponseWriter, r *http.Request) {
|
||||
func isURL(s string) bool {
|
||||
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
|
||||
}
|
||||
|
||||
func setPageSecurityHeaders(w http.ResponseWriter, r *http.Request, plugins []*plugin.Plugin) {
|
||||
c := config.GetInstance()
|
||||
|
||||
defaultSrc := "data: 'self' 'unsafe-inline'"
|
||||
connectSrc := "data: 'self'"
|
||||
connectSrcSlice := []string{
|
||||
"data:",
|
||||
"'self'",
|
||||
}
|
||||
imageSrc := "data: *"
|
||||
scriptSrc := "'self' http://www.gstatic.com https://www.gstatic.com 'unsafe-inline' 'unsafe-eval'"
|
||||
styleSrc := "'self' 'unsafe-inline'"
|
||||
scriptSrcSlice := []string{
|
||||
"'self'",
|
||||
"http://www.gstatic.com",
|
||||
"https://www.gstatic.com",
|
||||
"'unsafe-inline'",
|
||||
"'unsafe-eval'",
|
||||
}
|
||||
styleSrcSlice := []string{
|
||||
"'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:"
|
||||
connectSrcSlice = append(connectSrcSlice, "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"
|
||||
connectSrcSlice = append(connectSrcSlice, "https://cdn.jsdelivr.net")
|
||||
scriptSrcSlice = append(scriptSrcSlice, "https://cdn.jsdelivr.net")
|
||||
styleSrcSlice = append(styleSrcSlice, "https://cdn.jsdelivr.net")
|
||||
}
|
||||
|
||||
if !c.IsNewSystem() && c.GetHandyKey() != "" {
|
||||
connectSrc += " https://www.handyfeeling.com"
|
||||
connectSrcSlice = append(connectSrcSlice, "https://www.handyfeeling.com")
|
||||
}
|
||||
|
||||
for _, plugin := range plugins {
|
||||
if !plugin.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
ui := plugin.UI
|
||||
|
||||
for _, url := range ui.ExternalScript {
|
||||
if isURL(url) {
|
||||
scriptSrcSlice = append(scriptSrcSlice, url)
|
||||
}
|
||||
}
|
||||
|
||||
for _, url := range ui.ExternalCSS {
|
||||
if isURL(url) {
|
||||
styleSrcSlice = append(styleSrcSlice, url)
|
||||
}
|
||||
}
|
||||
|
||||
connectSrcSlice = append(connectSrcSlice, ui.CSP.ConnectSrc...)
|
||||
scriptSrcSlice = append(scriptSrcSlice, ui.CSP.ScriptSrc...)
|
||||
styleSrcSlice = append(styleSrcSlice, ui.CSP.StyleSrc...)
|
||||
}
|
||||
|
||||
connectSrc := strings.Join(connectSrcSlice, " ")
|
||||
scriptSrc := strings.Join(scriptSrcSlice, " ")
|
||||
styleSrc := strings.Join(styleSrcSlice, " ")
|
||||
|
||||
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';"
|
||||
|
||||
|
||||
@@ -50,7 +50,9 @@ func serveLoginPage(loginUIBox fs.FS, w http.ResponseWriter, r *http.Request, re
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
setPageSecurityHeaders(w, r)
|
||||
|
||||
// we shouldn't need to set plugin exceptions here
|
||||
setPageSecurityHeaders(w, r, nil)
|
||||
|
||||
utils.ServeStaticContent(w, r, buffer.Bytes())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user