mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01: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:
parent
4dd4c3c658
commit
222475df82
20 changed files with 621 additions and 105 deletions
|
|
@ -24,6 +24,11 @@ query Plugins {
|
||||||
description
|
description
|
||||||
type
|
type
|
||||||
}
|
}
|
||||||
|
|
||||||
|
paths {
|
||||||
|
css
|
||||||
|
javascript
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,10 @@
|
||||||
|
type PluginPaths {
|
||||||
|
# path to javascript files
|
||||||
|
javascript: [String!]
|
||||||
|
# path to css files
|
||||||
|
css: [String!]
|
||||||
|
}
|
||||||
|
|
||||||
type Plugin {
|
type Plugin {
|
||||||
id: ID!
|
id: ID!
|
||||||
name: String!
|
name: String!
|
||||||
|
|
@ -10,6 +17,8 @@ type Plugin {
|
||||||
tasks: [PluginTask!]
|
tasks: [PluginTask!]
|
||||||
hooks: [PluginHook!]
|
hooks: [PluginHook!]
|
||||||
settings: [PluginSetting!]
|
settings: [PluginSetting!]
|
||||||
|
|
||||||
|
paths: PluginPaths!
|
||||||
}
|
}
|
||||||
|
|
||||||
type PluginTask {
|
type PluginTask {
|
||||||
|
|
|
||||||
|
|
@ -13,4 +13,5 @@ const (
|
||||||
tagKey
|
tagKey
|
||||||
downloadKey
|
downloadKey
|
||||||
imageKey
|
imageKey
|
||||||
|
pluginKey
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,9 @@ func (r *Resolver) Tag() TagResolver {
|
||||||
func (r *Resolver) SavedFilter() SavedFilterResolver {
|
func (r *Resolver) SavedFilter() SavedFilterResolver {
|
||||||
return &savedFilterResolver{r}
|
return &savedFilterResolver{r}
|
||||||
}
|
}
|
||||||
|
func (r *Resolver) Plugin() PluginResolver {
|
||||||
|
return &pluginResolver{r}
|
||||||
|
}
|
||||||
func (r *Resolver) ConfigResult() ConfigResultResolver {
|
func (r *Resolver) ConfigResult() ConfigResultResolver {
|
||||||
return &configResultResolver{r}
|
return &configResultResolver{r}
|
||||||
}
|
}
|
||||||
|
|
@ -102,6 +105,7 @@ type studioResolver struct{ *Resolver }
|
||||||
type movieResolver struct{ *Resolver }
|
type movieResolver struct{ *Resolver }
|
||||||
type tagResolver struct{ *Resolver }
|
type tagResolver struct{ *Resolver }
|
||||||
type savedFilterResolver struct{ *Resolver }
|
type savedFilterResolver struct{ *Resolver }
|
||||||
|
type pluginResolver struct{ *Resolver }
|
||||||
type configResultResolver struct{ *Resolver }
|
type configResultResolver struct{ *Resolver }
|
||||||
|
|
||||||
func (r *Resolver) withTxn(ctx context.Context, fn func(ctx context.Context) error) error {
|
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"
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/stashapp/stash/pkg/utils"
|
||||||
"github.com/stashapp/stash/internal/manager/config"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type customRoutes struct {
|
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()
|
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(gqlEndpoint, gqlHandlerFunc)
|
||||||
r.HandleFunc(playgroundEndpoint, func(w http.ResponseWriter, r *http.Request) {
|
r.HandleFunc(playgroundEndpoint, func(w http.ResponseWriter, r *http.Request) {
|
||||||
setPageSecurityHeaders(w, r)
|
setPageSecurityHeaders(w, r, pluginCache.ListPlugins())
|
||||||
endpoint := getProxyPrefix(r) + gqlEndpoint
|
endpoint := getProxyPrefix(r) + gqlEndpoint
|
||||||
gqlPlayground.Handler("GraphQL playground", endpoint)(w, r)
|
gqlPlayground.Handler("GraphQL playground", endpoint)(w, r)
|
||||||
})
|
})
|
||||||
|
|
@ -150,9 +150,10 @@ func Start() error {
|
||||||
r.Mount("/movie", getMovieRoutes(repo))
|
r.Mount("/movie", getMovieRoutes(repo))
|
||||||
r.Mount("/tag", getTagRoutes(repo))
|
r.Mount("/tag", getTagRoutes(repo))
|
||||||
r.Mount("/downloads", getDownloadsRoutes())
|
r.Mount("/downloads", getDownloadsRoutes())
|
||||||
|
r.Mount("/plugin", getPluginRoutes(pluginCache))
|
||||||
|
|
||||||
r.HandleFunc("/css", cssHandler(c, pluginCache))
|
r.HandleFunc("/css", cssHandler(c))
|
||||||
r.HandleFunc("/javascript", javascriptHandler(c, pluginCache))
|
r.HandleFunc("/javascript", javascriptHandler(c))
|
||||||
r.HandleFunc("/customlocales", customLocalesHandler(c))
|
r.HandleFunc("/customlocales", customLocalesHandler(c))
|
||||||
|
|
||||||
staticLoginUI := statigz.FileServer(loginUIBox.(fs.ReadDirFS))
|
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)
|
indexHtml = strings.Replace(indexHtml, `<base href="/"`, fmt.Sprintf(`<base href="%s/"`, prefix), 1)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
setPageSecurityHeaders(w, r)
|
setPageSecurityHeaders(w, r, pluginCache.ListPlugins())
|
||||||
|
|
||||||
utils.ServeStaticContent(w, r, []byte(indexHtml))
|
utils.ServeStaticContent(w, r, []byte(indexHtml))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -289,19 +290,10 @@ func serveFiles(w http.ResponseWriter, r *http.Request, paths []string) {
|
||||||
utils.ServeStaticContent(w, r, buffer.Bytes())
|
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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
// add plugin css files first
|
|
||||||
var paths []string
|
var paths []string
|
||||||
|
|
||||||
for _, p := range pluginCache.ListPlugins() {
|
|
||||||
if !p.Enabled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
paths = append(paths, p.UI.CSS...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.GetCSSEnabled() {
|
if c.GetCSSEnabled() {
|
||||||
// search for custom.css in current directory, then $HOME/.stash
|
// search for custom.css in current directory, then $HOME/.stash
|
||||||
fn := c.GetCSSPath()
|
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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
// add plugin javascript files first
|
|
||||||
var paths []string
|
var paths []string
|
||||||
|
|
||||||
for _, p := range pluginCache.ListPlugins() {
|
|
||||||
if !p.Enabled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
paths = append(paths, p.UI.Javascript...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.GetJavascriptEnabled() {
|
if c.GetJavascriptEnabled() {
|
||||||
// search for custom.js in current directory, then $HOME/.stash
|
// search for custom.js in current directory, then $HOME/.stash
|
||||||
fn := c.GetJavascriptPath()
|
fn := c.GetJavascriptPath()
|
||||||
|
|
@ -408,31 +391,75 @@ func makeTLSConfig(c *config.Instance) (*tls.Config, error) {
|
||||||
return tlsConfig, nil
|
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()
|
c := config.GetInstance()
|
||||||
|
|
||||||
defaultSrc := "data: 'self' 'unsafe-inline'"
|
defaultSrc := "data: 'self' 'unsafe-inline'"
|
||||||
connectSrc := "data: 'self'"
|
connectSrcSlice := []string{
|
||||||
|
"data:",
|
||||||
|
"'self'",
|
||||||
|
}
|
||||||
imageSrc := "data: *"
|
imageSrc := "data: *"
|
||||||
scriptSrc := "'self' http://www.gstatic.com https://www.gstatic.com 'unsafe-inline' 'unsafe-eval'"
|
scriptSrcSlice := []string{
|
||||||
styleSrc := "'self' 'unsafe-inline'"
|
"'self'",
|
||||||
|
"http://www.gstatic.com",
|
||||||
|
"https://www.gstatic.com",
|
||||||
|
"'unsafe-inline'",
|
||||||
|
"'unsafe-eval'",
|
||||||
|
}
|
||||||
|
styleSrcSlice := []string{
|
||||||
|
"'self'",
|
||||||
|
"'unsafe-inline'",
|
||||||
|
}
|
||||||
mediaSrc := "blob: 'self'"
|
mediaSrc := "blob: 'self'"
|
||||||
|
|
||||||
// Workaround Safari bug https://bugs.webkit.org/show_bug.cgi?id=201591
|
// Workaround Safari bug https://bugs.webkit.org/show_bug.cgi?id=201591
|
||||||
// Allows websocket requests to any origin
|
// Allows websocket requests to any origin
|
||||||
connectSrc += " ws: wss:"
|
connectSrcSlice = append(connectSrcSlice, "ws:", "wss:")
|
||||||
|
|
||||||
// The graphql playground pulls its frontend from a cdn
|
// The graphql playground pulls its frontend from a cdn
|
||||||
if r.URL.Path == playgroundEndpoint {
|
if r.URL.Path == playgroundEndpoint {
|
||||||
connectSrc += " https://cdn.jsdelivr.net"
|
connectSrcSlice = append(connectSrcSlice, "https://cdn.jsdelivr.net")
|
||||||
scriptSrc += " https://cdn.jsdelivr.net"
|
scriptSrcSlice = append(scriptSrcSlice, "https://cdn.jsdelivr.net")
|
||||||
styleSrc += " https://cdn.jsdelivr.net"
|
styleSrcSlice = append(styleSrcSlice, "https://cdn.jsdelivr.net")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !c.IsNewSystem() && c.GetHandyKey() != "" {
|
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 := 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';"
|
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")
|
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())
|
utils.ServeStaticContent(w, r, buffer.Bytes())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import (
|
||||||
"github.com/stashapp/stash/pkg/logger"
|
"github.com/stashapp/stash/pkg/logger"
|
||||||
"github.com/stashapp/stash/pkg/models"
|
"github.com/stashapp/stash/pkg/models"
|
||||||
"github.com/stashapp/stash/pkg/models/paths"
|
"github.com/stashapp/stash/pkg/models/paths"
|
||||||
|
"github.com/stashapp/stash/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -1019,7 +1020,7 @@ func (i *Instance) GetMaxSessionAge() int {
|
||||||
|
|
||||||
// GetCustomServedFolders gets the map of custom paths to their applicable
|
// GetCustomServedFolders gets the map of custom paths to their applicable
|
||||||
// filesystem locations
|
// filesystem locations
|
||||||
func (i *Instance) GetCustomServedFolders() URLMap {
|
func (i *Instance) GetCustomServedFolders() utils.URLMap {
|
||||||
return i.getStringMapString(CustomServedFolders)
|
return i.getStringMapString(CustomServedFolders)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
package config
|
|
||||||
|
|
||||||
import "strings"
|
|
||||||
|
|
||||||
type URLMap map[string]string
|
|
||||||
|
|
||||||
// GetFilesystemLocation returns the adjusted URL and the filesystem location
|
|
||||||
func (m URLMap) GetFilesystemLocation(url string) (string, string) {
|
|
||||||
root := m["/"]
|
|
||||||
for k, v := range m {
|
|
||||||
if k != "/" && strings.HasPrefix(url, k) {
|
|
||||||
return strings.TrimPrefix(url, k), v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if root != "" {
|
|
||||||
return url, root
|
|
||||||
}
|
|
||||||
|
|
||||||
return url, ""
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestURLMapGetFilesystemLocation(t *testing.T) {
|
|
||||||
// create the URLMap
|
|
||||||
urlMap := make(URLMap)
|
|
||||||
urlMap["/"] = "root"
|
|
||||||
urlMap["/foo"] = "bar"
|
|
||||||
|
|
||||||
url, fs := urlMap.GetFilesystemLocation("/foo/bar")
|
|
||||||
assert.Equal(t, "/bar", url)
|
|
||||||
assert.Equal(t, urlMap["/foo"], fs)
|
|
||||||
|
|
||||||
url, fs = urlMap.GetFilesystemLocation("/bar")
|
|
||||||
assert.Equal(t, "/bar", url)
|
|
||||||
assert.Equal(t, urlMap["/"], fs)
|
|
||||||
|
|
||||||
delete(urlMap, "/")
|
|
||||||
|
|
||||||
url, fs = urlMap.GetFilesystemLocation("/bar")
|
|
||||||
assert.Equal(t, "/bar", url)
|
|
||||||
assert.Equal(t, "", fs)
|
|
||||||
}
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/stashapp/stash/pkg/utils"
|
||||||
"gopkg.in/yaml.v2"
|
"gopkg.in/yaml.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -64,27 +65,81 @@ type Config struct {
|
||||||
Settings map[string]SettingConfig `yaml:"settings"`
|
Settings map[string]SettingConfig `yaml:"settings"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PluginCSP struct {
|
||||||
|
ScriptSrc []string `json:"script-src" yaml:"script-src"`
|
||||||
|
StyleSrc []string `json:"style-src" yaml:"style-src"`
|
||||||
|
ConnectSrc []string `json:"connect-src" yaml:"connect-src"`
|
||||||
|
}
|
||||||
|
|
||||||
type UIConfig struct {
|
type UIConfig struct {
|
||||||
|
// Content Security Policy configuration for the plugin.
|
||||||
|
CSP PluginCSP `yaml:"csp"`
|
||||||
|
|
||||||
// Javascript files that will be injected into the stash UI.
|
// Javascript files that will be injected into the stash UI.
|
||||||
|
// These may be URLs or paths to files relative to the plugin configuration file.
|
||||||
Javascript []string `yaml:"javascript"`
|
Javascript []string `yaml:"javascript"`
|
||||||
|
|
||||||
// CSS files that will be injected into the stash UI.
|
// CSS files that will be injected into the stash UI.
|
||||||
|
// These may be URLs or paths to files relative to the plugin configuration file.
|
||||||
CSS []string `yaml:"css"`
|
CSS []string `yaml:"css"`
|
||||||
|
|
||||||
|
// Assets is a map of URL prefixes to hosted directories.
|
||||||
|
// This allows plugins to serve static assets from a URL path.
|
||||||
|
// Plugin assets are exposed via the /plugin/{pluginId}/assets path.
|
||||||
|
// For example, if the plugin configuration file contains:
|
||||||
|
// /foo: bar
|
||||||
|
// /bar: baz
|
||||||
|
// /: root
|
||||||
|
// Then the following requests will be mapped to the following files:
|
||||||
|
// /plugin/{pluginId}/assets/foo/file.txt -> {pluginDir}/foo/file.txt
|
||||||
|
// /plugin/{pluginId}/assets/bar/file.txt -> {pluginDir}/baz/file.txt
|
||||||
|
// /plugin/{pluginId}/assets/file.txt -> {pluginDir}/root/file.txt
|
||||||
|
Assets utils.URLMap `yaml:"assets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func isURL(s string) bool {
|
||||||
|
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c UIConfig) getCSSFiles(parent Config) []string {
|
func (c UIConfig) getCSSFiles(parent Config) []string {
|
||||||
ret := make([]string, len(c.CSS))
|
var ret []string
|
||||||
for i, v := range c.CSS {
|
for _, v := range c.CSS {
|
||||||
ret[i] = filepath.Join(parent.getConfigPath(), v)
|
if !isURL(v) {
|
||||||
|
ret = append(ret, filepath.Join(parent.getConfigPath(), v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c UIConfig) getExternalCSS() []string {
|
||||||
|
var ret []string
|
||||||
|
for _, v := range c.CSS {
|
||||||
|
if isURL(v) {
|
||||||
|
ret = append(ret, v)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c UIConfig) getJavascriptFiles(parent Config) []string {
|
func (c UIConfig) getJavascriptFiles(parent Config) []string {
|
||||||
ret := make([]string, len(c.Javascript))
|
var ret []string
|
||||||
for i, v := range c.Javascript {
|
for _, v := range c.Javascript {
|
||||||
ret[i] = filepath.Join(parent.getConfigPath(), v)
|
if !isURL(v) {
|
||||||
|
ret = append(ret, filepath.Join(parent.getConfigPath(), v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c UIConfig) getExternalScripts() []string {
|
||||||
|
var ret []string
|
||||||
|
for _, v := range c.Javascript {
|
||||||
|
if isURL(v) {
|
||||||
|
ret = append(ret, v)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
|
|
@ -184,10 +239,14 @@ func (c Config) toPlugin() *Plugin {
|
||||||
Tasks: c.getPluginTasks(false),
|
Tasks: c.getPluginTasks(false),
|
||||||
Hooks: c.getPluginHooks(false),
|
Hooks: c.getPluginHooks(false),
|
||||||
UI: PluginUI{
|
UI: PluginUI{
|
||||||
|
ExternalScript: c.UI.getExternalScripts(),
|
||||||
|
ExternalCSS: c.UI.getExternalCSS(),
|
||||||
Javascript: c.UI.getJavascriptFiles(c),
|
Javascript: c.UI.getJavascriptFiles(c),
|
||||||
CSS: c.UI.getCSSFiles(c),
|
CSS: c.UI.getCSSFiles(c),
|
||||||
|
Assets: c.UI.Assets,
|
||||||
},
|
},
|
||||||
Settings: c.getPluginSettings(),
|
Settings: c.getPluginSettings(),
|
||||||
|
ConfigPath: c.path,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import (
|
||||||
"github.com/stashapp/stash/pkg/session"
|
"github.com/stashapp/stash/pkg/session"
|
||||||
"github.com/stashapp/stash/pkg/sliceutil"
|
"github.com/stashapp/stash/pkg/sliceutil"
|
||||||
"github.com/stashapp/stash/pkg/txn"
|
"github.com/stashapp/stash/pkg/txn"
|
||||||
|
"github.com/stashapp/stash/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Plugin struct {
|
type Plugin struct {
|
||||||
|
|
@ -35,14 +36,39 @@ type Plugin struct {
|
||||||
Settings []PluginSetting `json:"settings"`
|
Settings []PluginSetting `json:"settings"`
|
||||||
|
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
|
|
||||||
|
// ConfigPath is the path to the plugin's configuration file.
|
||||||
|
ConfigPath string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PluginUI struct {
|
type PluginUI struct {
|
||||||
|
// Content Security Policy configuration for the plugin.
|
||||||
|
CSP PluginCSP `json:"csp"`
|
||||||
|
|
||||||
|
// External Javascript files that will be injected into the stash UI.
|
||||||
|
ExternalScript []string `json:"external_script"`
|
||||||
|
|
||||||
|
// External CSS files that will be injected into the stash UI.
|
||||||
|
ExternalCSS []string `json:"external_css"`
|
||||||
|
|
||||||
// Javascript files that will be injected into the stash UI.
|
// Javascript files that will be injected into the stash UI.
|
||||||
Javascript []string `json:"javascript"`
|
Javascript []string `json:"javascript"`
|
||||||
|
|
||||||
// CSS files that will be injected into the stash UI.
|
// CSS files that will be injected into the stash UI.
|
||||||
CSS []string `json:"css"`
|
CSS []string `json:"css"`
|
||||||
|
|
||||||
|
// Assets is a map of URL prefixes to hosted directories.
|
||||||
|
// This allows plugins to serve static assets from a URL path.
|
||||||
|
// Plugin assets are exposed via the /plugin/{pluginId}/assets path.
|
||||||
|
// For example, if the plugin configuration file contains:
|
||||||
|
// /foo: bar
|
||||||
|
// /bar: baz
|
||||||
|
// /: root
|
||||||
|
// Then the following requests will be mapped to the following files:
|
||||||
|
// /plugin/{pluginId}/assets/foo/file.txt -> {pluginDir}/foo/file.txt
|
||||||
|
// /plugin/{pluginId}/assets/bar/file.txt -> {pluginDir}/baz/file.txt
|
||||||
|
// /plugin/{pluginId}/assets/file.txt -> {pluginDir}/root/file.txt
|
||||||
|
Assets utils.URLMap `json:"assets"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PluginSetting struct {
|
type PluginSetting struct {
|
||||||
|
|
@ -173,6 +199,22 @@ func (c Cache) ListPlugins() []*Plugin {
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPlugin returns the plugin with the given ID.
|
||||||
|
// Returns nil if the plugin is not found.
|
||||||
|
func (c Cache) GetPlugin(id string) *Plugin {
|
||||||
|
disabledPlugins := c.config.GetDisabledPlugins()
|
||||||
|
plugin := c.getPlugin(id)
|
||||||
|
if plugin != nil {
|
||||||
|
p := plugin.toPlugin()
|
||||||
|
|
||||||
|
disabled := sliceutil.Contains(disabledPlugins, p.ID)
|
||||||
|
p.Enabled = !disabled
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListPluginTasks returns all runnable plugin tasks in all loaded plugins.
|
// ListPluginTasks returns all runnable plugin tasks in all loaded plugins.
|
||||||
func (c Cache) ListPluginTasks() []*PluginTask {
|
func (c Cache) ListPluginTasks() []*PluginTask {
|
||||||
var ret []*PluginTask
|
var ret []*PluginTask
|
||||||
|
|
|
||||||
30
pkg/utils/urlmap.go
Normal file
30
pkg/utils/urlmap.go
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// URLMap is a map of URL prefixes to filesystem locations
|
||||||
|
type URLMap map[string]string
|
||||||
|
|
||||||
|
// GetFilesystemLocation returns the adjusted URL and the filesystem location
|
||||||
|
func (m URLMap) GetFilesystemLocation(url string) (newURL string, fsPath string) {
|
||||||
|
newURL = url
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
root := m["/"]
|
||||||
|
for k, v := range m {
|
||||||
|
if k != "/" && strings.HasPrefix(url, k) {
|
||||||
|
newURL = strings.TrimPrefix(url, k)
|
||||||
|
fsPath = v
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if root != "" {
|
||||||
|
fsPath = root
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
70
pkg/utils/urlmap_test.go
Normal file
70
pkg/utils/urlmap_test.go
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestURLMap_GetFilesystemLocation(t *testing.T) {
|
||||||
|
// create the URLMap
|
||||||
|
urlMap := make(URLMap)
|
||||||
|
urlMap["/"] = "root"
|
||||||
|
urlMap["/foo"] = "bar"
|
||||||
|
|
||||||
|
empty := make(URLMap)
|
||||||
|
var nilMap URLMap
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
urlMap URLMap
|
||||||
|
url string
|
||||||
|
wantNewURL string
|
||||||
|
wantFsPath string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple",
|
||||||
|
urlMap: urlMap,
|
||||||
|
url: "/foo/bar",
|
||||||
|
wantNewURL: "/bar",
|
||||||
|
wantFsPath: "bar",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "root",
|
||||||
|
urlMap: urlMap,
|
||||||
|
url: "/baz",
|
||||||
|
wantNewURL: "/baz",
|
||||||
|
wantFsPath: "root",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "root",
|
||||||
|
urlMap: urlMap,
|
||||||
|
url: "/baz",
|
||||||
|
wantNewURL: "/baz",
|
||||||
|
wantFsPath: "root",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
urlMap: empty,
|
||||||
|
url: "/xyz",
|
||||||
|
wantNewURL: "/xyz",
|
||||||
|
wantFsPath: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil",
|
||||||
|
urlMap: nilMap,
|
||||||
|
url: "/xyz",
|
||||||
|
wantNewURL: "/xyz",
|
||||||
|
wantFsPath: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gotNewURL, gotFsPath := tt.urlMap.GetFilesystemLocation(tt.url)
|
||||||
|
if gotNewURL != tt.wantNewURL {
|
||||||
|
t.Errorf("URLMap.GetFilesystemLocation() gotNewURL = %v, want %v", gotNewURL, tt.wantNewURL)
|
||||||
|
}
|
||||||
|
if gotFsPath != tt.wantFsPath {
|
||||||
|
t.Errorf("URLMap.GetFilesystemLocation() gotFsPath = %v, want %v", gotFsPath, tt.wantFsPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import locales, { registerCountry } from "src/locales";
|
||||||
import {
|
import {
|
||||||
useConfiguration,
|
useConfiguration,
|
||||||
useConfigureUI,
|
useConfigureUI,
|
||||||
|
usePlugins,
|
||||||
useSystemStatus,
|
useSystemStatus,
|
||||||
} from "src/core/StashService";
|
} from "src/core/StashService";
|
||||||
import flattenMessages from "./utils/flattenMessages";
|
import flattenMessages from "./utils/flattenMessages";
|
||||||
|
|
@ -40,6 +41,9 @@ import { releaseNotes } from "./docs/en/ReleaseNotes";
|
||||||
import { getPlatformURL } from "./core/createClient";
|
import { getPlatformURL } from "./core/createClient";
|
||||||
import { lazyComponent } from "./utils/lazyComponent";
|
import { lazyComponent } from "./utils/lazyComponent";
|
||||||
import { isPlatformUniquelyRenderedByApple } from "./utils/apple";
|
import { isPlatformUniquelyRenderedByApple } from "./utils/apple";
|
||||||
|
import useScript, { useCSS } from "./hooks/useScript";
|
||||||
|
import { useMemoOnce } from "./hooks/state";
|
||||||
|
import { uniq } from "lodash-es";
|
||||||
|
|
||||||
const Performers = lazyComponent(
|
const Performers = lazyComponent(
|
||||||
() => import("./components/Performers/Performers")
|
() => import("./components/Performers/Performers")
|
||||||
|
|
@ -149,6 +153,39 @@ export const App: React.FC = () => {
|
||||||
setLocale();
|
setLocale();
|
||||||
}, [customMessages, language]);
|
}, [customMessages, language]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: plugins,
|
||||||
|
loading: pluginsLoading,
|
||||||
|
error: pluginsError,
|
||||||
|
} = usePlugins();
|
||||||
|
|
||||||
|
const pluginJavascripts = useMemoOnce(() => {
|
||||||
|
return [
|
||||||
|
uniq(
|
||||||
|
plugins?.plugins
|
||||||
|
?.filter((plugin) => plugin.enabled && plugin.paths.javascript)
|
||||||
|
.map((plugin) => plugin.paths.javascript!)
|
||||||
|
.flat() ?? []
|
||||||
|
),
|
||||||
|
!pluginsLoading && !pluginsError,
|
||||||
|
];
|
||||||
|
}, [plugins?.plugins, pluginsLoading, pluginsError]);
|
||||||
|
|
||||||
|
const pluginCSS = useMemoOnce(() => {
|
||||||
|
return [
|
||||||
|
uniq(
|
||||||
|
plugins?.plugins
|
||||||
|
?.filter((plugin) => plugin.enabled && plugin.paths.css)
|
||||||
|
.map((plugin) => plugin.paths.css!)
|
||||||
|
.flat() ?? []
|
||||||
|
),
|
||||||
|
!pluginsLoading && !pluginsError,
|
||||||
|
];
|
||||||
|
}, [plugins, pluginsLoading, pluginsError]);
|
||||||
|
|
||||||
|
useScript(pluginJavascripts ?? [], !pluginsLoading && !pluginsError);
|
||||||
|
useCSS(pluginCSS ?? [], !pluginsLoading && !pluginsError);
|
||||||
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const setupMatch = useRouteMatch(["/setup", "/migrate"]);
|
const setupMatch = useRouteMatch(["/setup", "/migrate"]);
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,22 @@ ui:
|
||||||
javascript:
|
javascript:
|
||||||
- <path to javascript file>
|
- <path to javascript file>
|
||||||
|
|
||||||
|
# optional list of assets
|
||||||
|
assets:
|
||||||
|
urlPrefix: fsLocation
|
||||||
|
...
|
||||||
|
|
||||||
|
# content-security policy overrides
|
||||||
|
csp:
|
||||||
|
script-src:
|
||||||
|
- http://alloweddomain.com
|
||||||
|
|
||||||
|
style-src:
|
||||||
|
- http://alloweddomain.com
|
||||||
|
|
||||||
|
connect-src:
|
||||||
|
- http://alloweddomain.com
|
||||||
|
|
||||||
# the following are used for plugin tasks only
|
# the following are used for plugin tasks only
|
||||||
exec:
|
exec:
|
||||||
- ...
|
- ...
|
||||||
|
|
@ -56,6 +72,31 @@ The `name`, `description`, `version` and `url` fields are displayed on the plugi
|
||||||
|
|
||||||
The `exec`, `interface`, `errLog` and `tasks` fields are used only for plugins with tasks.
|
The `exec`, `interface`, `errLog` and `tasks` fields are used only for plugins with tasks.
|
||||||
|
|
||||||
|
## UI Configuration
|
||||||
|
|
||||||
|
The `css` and `javascript` field values may be relative paths to the plugin configuration file, or
|
||||||
|
may be full external URLs.
|
||||||
|
|
||||||
|
The `assets` field is a map of URL prefixes to filesystem paths relative to the plugin configuration file.
|
||||||
|
Assets are mounted to the `/plugin/{pluginID}/assets` path.
|
||||||
|
|
||||||
|
As an example, for a plugin with id `foo` with the following `assets` value:
|
||||||
|
```
|
||||||
|
assets:
|
||||||
|
foo: bar
|
||||||
|
root: .
|
||||||
|
```
|
||||||
|
The following URLs will be mapped to these locations:
|
||||||
|
`/plugin/foo/assets/foo/file.txt` -> `{pluginDir}/bar/file.txt`
|
||||||
|
`/plugin/foo/assets/file.txt` -> `{pluginDir}/file.txt`
|
||||||
|
`/plugin/foo/assets/bar/file.txt` -> `{pluginDir}/bar/file.txt` (via the `root` entry)
|
||||||
|
|
||||||
|
Mappings that try to go outside of the directory containing the plugin configuration file will be
|
||||||
|
ignored.
|
||||||
|
|
||||||
|
The `csp` field contains overrides to the content security policies. The URLs in `script-src`,
|
||||||
|
`style-src` and `connect-src` will be added to the applicable content security policy.
|
||||||
|
|
||||||
See [External Plugins](/help/ExternalPlugins.md) for details for making plugins with external tasks.
|
See [External Plugins](/help/ExternalPlugins.md) for details for making plugins with external tasks.
|
||||||
|
|
||||||
See [Embedded Plugins](/help/EmbeddedPlugins.md) for details for making plugins with embedded tasks.
|
See [Embedded Plugins](/help/EmbeddedPlugins.md) for details for making plugins with embedded tasks.
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,30 @@ export function useInitialState<T>(
|
||||||
return [value, setValue, setInitialValue];
|
return [value, setValue, setInitialValue];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// useMemoOnce is a hook that returns a value once the ready flag is set to true.
|
||||||
|
// The value is only set once, and will not be updated once it has been set.
|
||||||
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
|
export function useMemoOnce<T>(
|
||||||
|
fn: () => [T, boolean],
|
||||||
|
deps: React.DependencyList
|
||||||
|
) {
|
||||||
|
const [storedValue, setStoredValue] = React.useState<T>();
|
||||||
|
const isFirst = React.useRef(true);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isFirst.current) {
|
||||||
|
const [v, ready] = fn();
|
||||||
|
if (ready) {
|
||||||
|
setStoredValue(v);
|
||||||
|
isFirst.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, deps);
|
||||||
|
|
||||||
|
return storedValue;
|
||||||
|
}
|
||||||
|
/* eslint-enable react-hooks/exhaustive-deps */
|
||||||
|
|
||||||
// useCompare is a hook that returns true if the value has changed since the last render.
|
// useCompare is a hook that returns true if the value has changed since the last render.
|
||||||
export function useCompare<T>(val: T) {
|
export function useCompare<T>(val: T) {
|
||||||
const prevVal = usePrevious(val);
|
const prevVal = usePrevious(val);
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,72 @@
|
||||||
import { useEffect } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
|
|
||||||
|
const useScript = (urls: string | string[], condition?: boolean) => {
|
||||||
|
const urlArray = useMemo(() => {
|
||||||
|
if (!Array.isArray(urls)) {
|
||||||
|
return [urls];
|
||||||
|
}
|
||||||
|
|
||||||
|
return urls;
|
||||||
|
}, [urls]);
|
||||||
|
|
||||||
const useScript = (url: string, condition?: boolean) => {
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const scripts = urlArray.map((url) => {
|
||||||
const script = document.createElement("script");
|
const script = document.createElement("script");
|
||||||
|
|
||||||
script.src = url;
|
script.src = url;
|
||||||
script.async = true;
|
script.async = true;
|
||||||
|
return script;
|
||||||
|
});
|
||||||
|
|
||||||
if (condition) {
|
if (condition) {
|
||||||
|
scripts.forEach((script) => {
|
||||||
document.head.appendChild(script);
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (condition) {
|
if (condition) {
|
||||||
|
scripts.forEach((script) => {
|
||||||
document.head.removeChild(script);
|
document.head.removeChild(script);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [url, condition]);
|
}, [urlArray, condition]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCSS = (urls: string | string[], condition?: boolean) => {
|
||||||
|
const urlArray = useMemo(() => {
|
||||||
|
if (!Array.isArray(urls)) {
|
||||||
|
return [urls];
|
||||||
|
}
|
||||||
|
|
||||||
|
return urls;
|
||||||
|
}, [urls]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const links = urlArray.map((url) => {
|
||||||
|
const link = document.createElement("link");
|
||||||
|
|
||||||
|
link.href = url;
|
||||||
|
link.rel = "stylesheet";
|
||||||
|
link.type = "text/css";
|
||||||
|
return link;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (condition) {
|
||||||
|
links.forEach((link) => {
|
||||||
|
document.head.appendChild(link);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (condition) {
|
||||||
|
links.forEach((link) => {
|
||||||
|
document.head.removeChild(link);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [urlArray, condition]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useScript;
|
export default useScript;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue