package plugin import ( "fmt" "io" "os" "os/exec" "path/filepath" "sort" "strings" "github.com/stashapp/stash/pkg/plugin/hook" "github.com/stashapp/stash/pkg/python" "github.com/stashapp/stash/pkg/utils" "gopkg.in/yaml.v2" ) // Config describes the configuration for a single plugin. type Config struct { id string // path to the configuration file path string // The name of the plugin. This will be displayed in the UI. Name string `yaml:"name"` // An optional description of what the plugin does. Description *string `yaml:"description"` // An optional URL for the plugin. URL *string `yaml:"url"` // An optional version string. Version *string `yaml:"version"` // The communication interface used when communicating with the spawned // plugin process. Defaults to 'raw' if not provided. Interface interfaceEnum `yaml:"interface"` // The command to execute for the operations in this plugin. The first // element should be the program name, and subsequent elements are passed // as arguments. // // Note: the execution process will search the path for the program, // then will attempt to find the program in the plugins // directory. The exe extension is not necessary on Windows platforms. // The current working directory is set to that of the stash process. Exec []string `yaml:"exec,flow"` // The default log level to output the plugin process's stderr stream. // Only used if the plugin does not encode its output using log level // control characters. // See package common/log for valid values. // If left unset, defaults to log.ErrorLevel. PluginErrLogLevel string `yaml:"errLog"` // The task configurations for tasks provided by this plugin. Tasks []*OperationConfig `yaml:"tasks"` // 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"` // Settings that will be used to configure the plugin. 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 { // Requires is a list of plugin IDs that this plugin depends on. // These plugins will be loaded before this plugin. Requires []string `yaml:"requires"` // Content Security Policy configuration for the plugin. CSP PluginCSP `yaml:"csp"` // 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"` // 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"` // 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 { 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 { 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 } type SettingConfig struct { // defaults to string Type PluginSettingTypeEnum `yaml:"type"` // defaults to key name DisplayName string `yaml:"displayName"` Description string `yaml:"description"` } func (c Config) getPluginTasks(includePlugin bool) []*PluginTask { var ret []*PluginTask for _, o := range c.Tasks { task := &PluginTask{ Name: o.Name, Description: &o.Description, } if includePlugin { task.Plugin = c.toPlugin() } ret = append(ret, task) } return ret } func (c Config) getPluginHooks(includePlugin bool) []*PluginHook { var ret []*PluginHook for _, o := range c.Hooks { hook := &PluginHook{ Name: o.Name, Description: &o.Description, Hooks: convertHooks(o.TriggeredBy), } if includePlugin { hook.Plugin = c.toPlugin() } ret = append(ret, hook) } return ret } func convertHooks(hooks []hook.TriggerEnum) []string { var ret []string for _, h := range hooks { ret = append(ret, h.String()) } return ret } func (c Config) getPluginSettings() []PluginSetting { ret := []PluginSetting{} var keys []string for k := range c.Settings { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { o := c.Settings[k] t := o.Type if t == "" { t = PluginSettingTypeEnumString } s := PluginSetting{ Name: k, DisplayName: o.DisplayName, Description: o.Description, Type: t, } ret = append(ret, s) } return ret } func (c Config) getName() string { if c.Name != "" { return c.Name } return c.id } func (c Config) toPlugin() *Plugin { return &Plugin{ ID: c.id, Name: c.getName(), Description: c.Description, URL: c.URL, Version: c.Version, Tasks: c.getPluginTasks(false), Hooks: c.getPluginHooks(false), UI: PluginUI{ Requires: c.UI.Requires, ExternalScript: c.UI.getExternalScripts(), ExternalCSS: c.UI.getExternalCSS(), Javascript: c.UI.getJavascriptFiles(c), CSS: c.UI.getCSSFiles(c), CSP: c.UI.CSP, Assets: c.UI.Assets, }, Settings: c.getPluginSettings(), ConfigPath: c.path, } } func (c Config) getTask(name string) *OperationConfig { for _, o := range c.Tasks { if o.Name == name { return o } } return nil } func (c Config) getHooks(hookType hook.TriggerEnum) []*HookConfig { var ret []*HookConfig for _, h := range c.Hooks { for _, t := range h.TriggeredBy { if hookType == t { ret = append(ret, h) } } } return ret } func (c Config) getConfigPath() string { return filepath.Dir(c.path) } func (c Config) getExecCommand(task *OperationConfig) []string { // #4859 - don't modify the original exec command ret := append([]string{}, c.Exec...) if task != nil { ret = append(ret, task.ExecArgs...) } // #4859 - don't use the plugin path in the exec command if it is a python command if len(ret) > 0 && !python.IsPythonCommand(ret[0]) { _, err := exec.LookPath(ret[0]) if err != nil { // change command to run from the plugin path pluginPath := filepath.Dir(c.path) ret[0] = filepath.Join(pluginPath, ret[0]) } } // replace {pluginDir} in arguments with that of the plugin directory dir := c.getConfigPath() for i, arg := range ret { if i == 0 { continue } ret[i] = strings.ReplaceAll(arg, "{pluginDir}", dir) } return ret } func (c Config) valid() error { if c.Interface != "" && !c.Interface.Valid() { return fmt.Errorf("invalid interface type %s", c.Interface) } for k, o := range c.Settings { if o.Type != "" && !o.Type.IsValid() { return fmt.Errorf("invalid type %s for setting %s", k, o.Type) } } return nil } type interfaceEnum string // Valid interfaceEnum values const ( // InterfaceEnumRPC indicates that the plugin uses the RPCRunner interface // declared in common/rpc.go. InterfaceEnumRPC interfaceEnum = "rpc" // InterfaceEnumRaw interfaces will have the common.PluginInput encoded as // json (but may be ignored), and output will be decoded as // common.PluginOutput. If this decoding fails, then the raw output will be // treated as the output. InterfaceEnumRaw interfaceEnum = "raw" InterfaceEnumJS interfaceEnum = "js" ) func (i interfaceEnum) Valid() bool { return i == InterfaceEnumRPC || i == InterfaceEnumRaw || i == InterfaceEnumJS } func (i *interfaceEnum) getTaskBuilder() taskBuilder { if *i == InterfaceEnumRaw { return &rawTaskBuilder{} } if *i == InterfaceEnumRPC { return &rpcTaskBuilder{} } if *i == InterfaceEnumJS { return &jsTaskBuilder{} } // shouldn't happen return nil } // OperationConfig describes the configuration for a single plugin operation // provided by a plugin. type OperationConfig struct { // Used to identify the operation. Must be unique within a plugin // configuration. This name is shown in the button for the operation // in the UI. Name string `yaml:"name"` // A short description of the operation. This description is shown below // the button in the UI. Description string `yaml:"description"` // A list of arguments that will be appended to the plugin's Exec arguments // when executing this operation. ExecArgs []string `yaml:"execArgs"` // A map of argument keys to their default values. The default value is // used if the applicable argument is not provided during the operation // call. DefaultArgs map[string]string `yaml:"defaultArgs"` } type HookConfig struct { OperationConfig `yaml:",inline"` // A list of stash operations that will be used to trigger this hook operation. TriggeredBy []hook.TriggerEnum `yaml:"triggeredBy"` } func loadPluginFromYAML(reader io.Reader) (*Config, error) { ret := &Config{} parser := yaml.NewDecoder(reader) parser.SetStrict(true) err := parser.Decode(&ret) if err != nil { return nil, err } if ret.Interface == "" { ret.Interface = InterfaceEnumRaw } if err := ret.valid(); err != nil { return nil, err } return ret, nil } func loadPluginFromYAMLFile(path string) (*Config, error) { file, err := os.Open(path) if err != nil { return nil, err } defer file.Close() ret, err := loadPluginFromYAML(file) if err != nil { return nil, err } // set id to the filename id := filepath.Base(path) ret.id = id[:strings.LastIndex(id, ".")] ret.path = path return ret, nil }