diff --git a/internal/api/server.go b/internal/api/server.go index 124c89739..27b3d7722 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1,10 +1,12 @@ package api import ( + "bytes" "context" "crypto/tls" "errors" "fmt" + "io" "io/fs" "net/http" "os" @@ -31,6 +33,7 @@ import ( "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/ui" ) @@ -166,36 +169,8 @@ func Start() error { }.Routes()) r.Mount("/downloads", downloadsRoutes{}.Routes()) - r.HandleFunc("/css", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/css") - if !c.GetCSSEnabled() { - return - } - - // search for custom.css in current directory, then $HOME/.stash - fn := c.GetCSSPath() - exists, _ := fsutil.FileExists(fn) - if !exists { - return - } - - http.ServeFile(w, r, fn) - }) - r.HandleFunc("/javascript", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/javascript") - if !c.GetJavascriptEnabled() { - return - } - - // search for custom.js in current directory, then $HOME/.stash - fn := c.GetJavascriptPath() - exists, _ := fsutil.FileExists(fn) - if !exists { - return - } - - http.ServeFile(w, r, fn) - }) + 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() { @@ -329,6 +304,93 @@ func Start() error { return nil } +func copyFile(w io.Writer, path string) (time.Time, error) { + f, err := os.Open(path) + if err != nil { + return time.Time{}, err + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return time.Time{}, err + } + + _, err = io.Copy(w, f) + + return info.ModTime(), err +} + +func serveFiles(w http.ResponseWriter, r *http.Request, name string, paths []string) { + buffer := bytes.Buffer{} + + latestModTime := time.Time{} + + for _, path := range paths { + modTime, 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")) + } + } + + bufferReader := bytes.NewReader(buffer.Bytes()) + http.ServeContent(w, r, name, latestModTime, bufferReader) +} + +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 + + for _, p := range pluginCache.ListPlugins() { + paths = append(paths, p.UI.CSS...) + } + + if c.GetCSSEnabled() { + // search for custom.css in current directory, then $HOME/.stash + fn := c.GetCSSPath() + exists, _ := fsutil.FileExists(fn) + if exists { + paths = append(paths, fn) + } + } + + serveFiles(w, r, "custom.css", 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 + + for _, p := range pluginCache.ListPlugins() { + paths = append(paths, p.UI.Javascript...) + } + + if c.GetJavascriptEnabled() { + // search for custom.js in current directory, then $HOME/.stash + fn := c.GetJavascriptPath() + exists, _ := fsutil.FileExists(fn) + if exists { + paths = append(paths, fn) + } + } + + serveFiles(w, r, "custom.js", paths) + } +} + func printVersion() { versionString := githash if config.IsOfficialBuild() { diff --git a/pkg/plugin/config.go b/pkg/plugin/config.go index 2a00c3ced..eac7289a8 100644 --- a/pkg/plugin/config.go +++ b/pkg/plugin/config.go @@ -56,6 +56,35 @@ type Config struct { // The hooks configurations for hooks registered by this plugin. Hooks []*HookConfig `yaml:"hooks"` + + // Javascript files that will be injected into the stash UI. + UI UIConfig `yaml:"ui"` +} + +type UIConfig struct { + // Javascript files that will be injected into the stash UI. + Javascript []string `yaml:"javascript"` + + // CSS files that will be injected into the stash UI. + CSS []string `yaml:"css"` +} + +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) + } + + 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) + } + + return ret } func (c Config) getPluginTasks(includePlugin bool) []*PluginTask { @@ -121,6 +150,10 @@ func (c Config) toPlugin() *Plugin { Version: c.Version, Tasks: c.getPluginTasks(false), Hooks: c.getPluginHooks(false), + UI: PluginUI{ + Javascript: c.UI.getJavascriptFiles(c), + CSS: c.UI.getCSSFiles(c), + }, } } diff --git a/pkg/plugin/plugins.go b/pkg/plugin/plugins.go index 5f74b1d8b..6cfb27bcd 100644 --- a/pkg/plugin/plugins.go +++ b/pkg/plugin/plugins.go @@ -31,6 +31,15 @@ type Plugin struct { Version *string `json:"version"` Tasks []*PluginTask `json:"tasks"` Hooks []*PluginHook `json:"hooks"` + UI PluginUI `json:"ui"` +} + +type PluginUI struct { + // 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"` } type ServerConfig interface { diff --git a/ui/v2.5/src/docs/en/Changelog/v0190.md b/ui/v2.5/src/docs/en/Changelog/v0190.md index 8c138f434..1fe661e20 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0190.md +++ b/ui/v2.5/src/docs/en/Changelog/v0190.md @@ -2,6 +2,7 @@ * Performer autotagging does not currently match on performer aliases. This will be addressed when finer control over the matching is implemented. ### ✨ New Features +* Added support for plugins to add injected CSS and Javascript to the UI. ([#3195](https://github.com/stashapp/stash/pull/3195)) * Added disambiguation field to Performers, to differentiate between performers with the same name. ([#3113](https://github.com/stashapp/stash/pull/3113)) ### 🎨 Improvements diff --git a/ui/v2.5/src/docs/en/Manual/EmbeddedPlugins.md b/ui/v2.5/src/docs/en/Manual/EmbeddedPlugins.md index 9b69f140c..cb0f3b1b8 100644 --- a/ui/v2.5/src/docs/en/Manual/EmbeddedPlugins.md +++ b/ui/v2.5/src/docs/en/Manual/EmbeddedPlugins.md @@ -1,20 +1,20 @@ -# Embedded Plugins +# Embedded Plugin Tasks -Embedded plugins are executed within the stash process using a scripting system. +Embedded plugin tasks are executed within the stash process using a scripting system. ## Supported script languages -Stash currently supports Javascript embedded plugins using [otto](https://github.com/robertkrimen/otto). +Stash currently supports Javascript embedded plugin tasks using [otto](https://github.com/robertkrimen/otto). # Javascript plugins ## Plugin input -The input is provided to Javascript plugins using the `input` global variable, and is an object based on the structure provided in the `Plugin input` section of the [Plugins](/help/Plugins.md) page. Note that the `server_connection` field should not be necessary in most embedded plugins. +The input is provided to Javascript plugin tasks using the `input` global variable, and is an object based on the structure provided in the `Plugin input` section of the [Plugins](/help/Plugins.md) page. Note that the `server_connection` field should not be necessary in most embedded plugins. ## Plugin output -The output of a Javascript plugin is derived from the evaluated value of the script. The output should conform to the structure provided in the `Plugin output` section of the [Plugins](/help/Plugins.md) page. +The output of a Javascript plugin task is derived from the evaluated value of the script. The output should conform to the structure provided in the `Plugin output` section of the [Plugins](/help/Plugins.md) page. There are a number of ways to return the plugin output: @@ -53,22 +53,6 @@ See the `Javascript API` section below on how to log with Javascript plugins. # Plugin configuration file format -The basic structure of an embedded plugin configuration file is as follows: - -``` -name: -description: -version: -url: -exec: - - -interface: [interface type] -tasks: - - ... -``` - -The `name`, `description`, `version` and `url` fields are displayed on the plugins page. - ## exec For embedded plugins, the `exec` field is a list with the first element being the path to the Javascript file that will be executed. It is expected that the path to the Javascript file is relative to the directory of the plugin configuration file. diff --git a/ui/v2.5/src/docs/en/Manual/ExternalPlugins.md b/ui/v2.5/src/docs/en/Manual/ExternalPlugins.md index aabe3ac7a..872cc31ec 100644 --- a/ui/v2.5/src/docs/en/Manual/ExternalPlugins.md +++ b/ui/v2.5/src/docs/en/Manual/ExternalPlugins.md @@ -1,10 +1,10 @@ -# External Plugins +# External Plugin Tasks -External plugins are executed by running an external binary. +External plugin tasks are executed by running an external binary. ## Plugin interfaces -Stash communicates with external plugins using an interface. Stash currently supports RPC and raw interface types. +Stash communicates with external plugin tasks using an interface. Stash currently supports RPC and raw interface types. ### RPC interface @@ -30,27 +30,9 @@ Plugins can log for specific levels or log progress by prefixing the output stri # Plugin configuration file format -The basic structure of an external plugin configuration file is as follows: - -``` -name: -description: -version: -url: -exec: - - - - -interface: [interface type] -errLog: [one of none trace, debug, info, warning, error] -tasks: - - ... -``` - -The `name`, `description`, `version` and `url` fields are displayed on the plugins page. - ## exec -For external plugins, the `exec` field is a list with the first element being the binary that will be executed, and the subsequent elements are the arguments passed. The execution process will search the path for the binary, then will attempt to find the program in the same directory as the plugin configuration file. The `exe` extension is not necessary on Windows systems. +For external plugin tasks, the `exec` field is a list with the first element being the binary that will be executed, and the subsequent elements are the arguments passed. The execution process will search the path for the binary, then will attempt to find the program in the same directory as the plugin configuration file. The `exe` extension is not necessary on Windows systems. > **⚠️ Note:** The plugin execution process sets the current working directory to that of the stash process. @@ -75,7 +57,7 @@ exec: ## interface -For external plugins, the `interface` field must be set to one of the following values: +For external plugin tasks, the `interface` field must be set to one of the following values: * `rpc` * `raw` @@ -89,7 +71,7 @@ The `errLog` field tells stash what the default log level should be when the plu # Task configuration -In addition to the standard task configuration, external tags may be configured with an optional `execArgs` field to add extra parameters to the execution arguments for the task. +In addition to the standard task configuration, external tasks may be configured with an optional `execArgs` field to add extra parameters to the execution arguments for the task. For example: diff --git a/ui/v2.5/src/docs/en/Manual/Plugins.md b/ui/v2.5/src/docs/en/Manual/Plugins.md index fbb224fcb..e7c80ded1 100644 --- a/ui/v2.5/src/docs/en/Manual/Plugins.md +++ b/ui/v2.5/src/docs/en/Manual/Plugins.md @@ -1,8 +1,12 @@ # Plugins -Stash supports the running tasks via plugins. Plugins can be implemented using embedded Javascript, or by calling an external binary. +Stash supports plugins that can do the following: +- perform custom tasks when triggered by the user from the Tasks page +- perform custom tasks when triggered from specific events +- add custom CSS to the UI +- add custom JavaScript to the UI -Stash also supports triggering of plugin hooks from specific stash operations. +Plugin tasks can be implemented using embedded Javascript, or by calling an external binary. > **⚠️ Note:** Plugin support is still experimental and is likely to change. @@ -20,13 +24,45 @@ Plugins provide tasks which can be run from the Tasks page. # Creating plugins -See [External Plugins](/help/ExternalPlugins.md) for details for making external plugins. +## Plugin configuration file format -See [Embedded Plugins](/help/EmbeddedPlugins.md) for details for making embedded plugins. +The basic structure of a plugin configuration file is as follows: -## Plugin input +``` +name: +description: +version: +url: -Plugins may accept an input from the stash server. This input is encoded according to the interface, and has the following structure (presented here in JSON format): +ui: + # optional list of css files to include in the UI + css: + - + + # optional list of js files to include in the UI + javascript: + - + +# the following are used for plugin tasks only +exec: + - ... +interface: [interface type] +errLog: [one of none trace, debug, info, warning, error] +tasks: + - ... +``` + +The `name`, `description`, `version` and `url` fields are displayed on the plugins page. + +The `exec`, `interface`, `errLog` and `tasks` fields are used only for plugins with 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. + +## Plugin task input + +Plugin tasks may accept an input from the stash server. This input is encoded according to the interface, and has the following structure (presented here in JSON format): ``` { "server_connection": { @@ -57,9 +93,9 @@ Plugins may accept an input from the stash server. This input is encoded accordi The `server_connection` field contains all the information needed for a plugin to access the parent stash server, if necessary. -## Plugin output +## Plugin task output -Plugin output is expected in the following structure (presented here as JSON format): +Plugin task output is expected in the following structure (presented here as JSON format): ``` {