diff --git a/graphql/documents/queries/plugins.graphql b/graphql/documents/queries/plugins.graphql index 901e1722b27..4c8bd00955a 100644 --- a/graphql/documents/queries/plugins.graphql +++ b/graphql/documents/queries/plugins.graphql @@ -24,6 +24,11 @@ query Plugins { description type } + + paths { + css + javascript + } } } diff --git a/graphql/schema/types/plugin.graphql b/graphql/schema/types/plugin.graphql index 45a2f55f1c1..a18f6651981 100644 --- a/graphql/schema/types/plugin.graphql +++ b/graphql/schema/types/plugin.graphql @@ -1,3 +1,10 @@ +type PluginPaths { + # path to javascript files + javascript: [String!] + # path to css files + css: [String!] +} + type Plugin { id: ID! name: String! @@ -10,6 +17,8 @@ type Plugin { tasks: [PluginTask!] hooks: [PluginHook!] settings: [PluginSetting!] + + paths: PluginPaths! } type PluginTask { diff --git a/internal/api/context_keys.go b/internal/api/context_keys.go index 8731f75c301..a8ab0afb50e 100644 --- a/internal/api/context_keys.go +++ b/internal/api/context_keys.go @@ -13,4 +13,5 @@ const ( tagKey downloadKey imageKey + pluginKey ) diff --git a/internal/api/resolver.go b/internal/api/resolver.go index 8c82be334e1..4698add7062 100644 --- a/internal/api/resolver.go +++ b/internal/api/resolver.go @@ -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 { diff --git a/internal/api/resolver_model_plugin.go b/internal/api/resolver_model_plugin.go new file mode 100644 index 00000000000..644e5e004c9 --- /dev/null +++ b/internal/api/resolver_model_plugin.go @@ -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 +} diff --git a/internal/api/routes_custom.go b/internal/api/routes_custom.go index 5c32d23b2c8..cd14375d7ad 100644 --- a/internal/api/routes_custom.go +++ b/internal/api/routes_custom.go @@ -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() } diff --git a/internal/api/routes_plugin.go b/internal/api/routes_plugin.go new file mode 100644 index 00000000000..a844552e902 --- /dev/null +++ b/internal/api/routes_plugin.go @@ -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)) + }) +} diff --git a/internal/api/server.go b/internal/api/server.go index fa605777785..bd5bcc2c1a6 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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, ` {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 { - ret := make([]string, len(c.CSS)) - for i, v := range c.CSS { - ret[i] = filepath.Join(parent.getConfigPath(), v) + var ret []string + for _, v := range c.CSS { + 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 } func (c UIConfig) getJavascriptFiles(parent Config) []string { - ret := make([]string, len(c.Javascript)) - for i, v := range c.Javascript { - ret[i] = filepath.Join(parent.getConfigPath(), v) + var ret []string + for _, v := range c.Javascript { + 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 @@ -184,10 +239,14 @@ func (c Config) toPlugin() *Plugin { Tasks: c.getPluginTasks(false), Hooks: c.getPluginHooks(false), UI: PluginUI{ - Javascript: c.UI.getJavascriptFiles(c), - CSS: c.UI.getCSSFiles(c), + ExternalScript: c.UI.getExternalScripts(), + ExternalCSS: c.UI.getExternalCSS(), + Javascript: c.UI.getJavascriptFiles(c), + CSS: c.UI.getCSSFiles(c), + Assets: c.UI.Assets, }, - Settings: c.getPluginSettings(), + Settings: c.getPluginSettings(), + ConfigPath: c.path, } } diff --git a/pkg/plugin/plugins.go b/pkg/plugin/plugins.go index 8aef1b14bef..2003ea5ff92 100644 --- a/pkg/plugin/plugins.go +++ b/pkg/plugin/plugins.go @@ -21,6 +21,7 @@ import ( "github.com/stashapp/stash/pkg/session" "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/txn" + "github.com/stashapp/stash/pkg/utils" ) type Plugin struct { @@ -35,14 +36,39 @@ type Plugin struct { Settings []PluginSetting `json:"settings"` Enabled bool `json:"enabled"` + + // ConfigPath is the path to the plugin's configuration file. + ConfigPath string `json:"-"` } 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 []string `json:"javascript"` // CSS files that will be injected into the stash UI. 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 { @@ -173,6 +199,22 @@ func (c Cache) ListPlugins() []*Plugin { 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. func (c Cache) ListPluginTasks() []*PluginTask { var ret []*PluginTask diff --git a/pkg/utils/urlmap.go b/pkg/utils/urlmap.go new file mode 100644 index 00000000000..5279b00db3c --- /dev/null +++ b/pkg/utils/urlmap.go @@ -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 +} diff --git a/pkg/utils/urlmap_test.go b/pkg/utils/urlmap_test.go new file mode 100644 index 00000000000..12c7fa29032 --- /dev/null +++ b/pkg/utils/urlmap_test.go @@ -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) + } + }) + } +} diff --git a/ui/v2.5/src/App.tsx b/ui/v2.5/src/App.tsx index e85ecb2fb32..d3942b16674 100644 --- a/ui/v2.5/src/App.tsx +++ b/ui/v2.5/src/App.tsx @@ -18,6 +18,7 @@ import locales, { registerCountry } from "src/locales"; import { useConfiguration, useConfigureUI, + usePlugins, useSystemStatus, } from "src/core/StashService"; import flattenMessages from "./utils/flattenMessages"; @@ -40,6 +41,9 @@ import { releaseNotes } from "./docs/en/ReleaseNotes"; import { getPlatformURL } from "./core/createClient"; import { lazyComponent } from "./utils/lazyComponent"; import { isPlatformUniquelyRenderedByApple } from "./utils/apple"; +import useScript, { useCSS } from "./hooks/useScript"; +import { useMemoOnce } from "./hooks/state"; +import { uniq } from "lodash-es"; const Performers = lazyComponent( () => import("./components/Performers/Performers") @@ -149,6 +153,39 @@ export const App: React.FC = () => { setLocale(); }, [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 history = useHistory(); const setupMatch = useRouteMatch(["/setup", "/migrate"]); diff --git a/ui/v2.5/src/docs/en/Manual/Plugins.md b/ui/v2.5/src/docs/en/Manual/Plugins.md index e7c80ded170..20488cebf0d 100644 --- a/ui/v2.5/src/docs/en/Manual/Plugins.md +++ b/ui/v2.5/src/docs/en/Manual/Plugins.md @@ -43,6 +43,22 @@ ui: javascript: - + # 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 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. +## 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 [Embedded Plugins](/help/EmbeddedPlugins.md) for details for making plugins with embedded tasks. diff --git a/ui/v2.5/src/hooks/state.ts b/ui/v2.5/src/hooks/state.ts index 9c10523bd6f..ef91f6904a2 100644 --- a/ui/v2.5/src/hooks/state.ts +++ b/ui/v2.5/src/hooks/state.ts @@ -33,6 +33,30 @@ export function useInitialState( 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( + fn: () => [T, boolean], + deps: React.DependencyList +) { + const [storedValue, setStoredValue] = React.useState(); + 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. export function useCompare(val: T) { const prevVal = usePrevious(val); diff --git a/ui/v2.5/src/hooks/useScript.tsx b/ui/v2.5/src/hooks/useScript.tsx index 1745a7483da..12dee9ce2df 100644 --- a/ui/v2.5/src/hooks/useScript.tsx +++ b/ui/v2.5/src/hooks/useScript.tsx @@ -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]); + + useEffect(() => { + const scripts = urlArray.map((url) => { + const script = document.createElement("script"); + + script.src = url; + script.async = true; + return script; + }); + + if (condition) { + scripts.forEach((script) => { + document.head.appendChild(script); + }); + } + + return () => { + if (condition) { + scripts.forEach((script) => { + document.head.removeChild(script); + }); + } + }; + }, [urlArray, condition]); +}; + +export const useCSS = (urls: string | string[], condition?: boolean) => { + const urlArray = useMemo(() => { + if (!Array.isArray(urls)) { + return [urls]; + } + + return urls; + }, [urls]); -const useScript = (url: string, condition?: boolean) => { useEffect(() => { - const script = document.createElement("script"); + const links = urlArray.map((url) => { + const link = document.createElement("link"); - script.src = url; - script.async = true; + link.href = url; + link.rel = "stylesheet"; + link.type = "text/css"; + return link; + }); if (condition) { - document.head.appendChild(script); + links.forEach((link) => { + document.head.appendChild(link); + }); } return () => { if (condition) { - document.head.removeChild(script); + links.forEach((link) => { + document.head.removeChild(link); + }); } }; - }, [url, condition]); + }, [urlArray, condition]); }; export default useScript;