// Package plugin implements functions and types for maintaining and running // stash plugins. // // Stash plugins are configured using yml files in the configured plugins // directory. These yml files must follow the Config structure format. // // The main entry into the plugin sub-system is via the Cache type. package plugin import ( "context" "errors" "fmt" "net/http" "os" "path/filepath" "slices" "strconv" "strings" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/plugin/common" "github.com/stashapp/stash/pkg/plugin/hook" "github.com/stashapp/stash/pkg/session" "github.com/stashapp/stash/pkg/txn" "github.com/stashapp/stash/pkg/utils" ) type Plugin struct { ID string `json:"id"` Name string `json:"name"` Description *string `json:"description"` URL *string `json:"url"` Version *string `json:"version"` Tasks []*PluginTask `json:"tasks"` Hooks []*PluginHook `json:"hooks"` UI PluginUI `json:"ui"` Settings []PluginSetting `json:"settings"` Enabled bool `json:"enabled"` // ConfigPath is the path to the plugin's configuration file. ConfigPath string `json:"-"` } type PluginUI struct { // Requires is a list of plugin IDs that this plugin depends on. // These plugins will be loaded before this plugin. Requires []string `json:"requires"` // 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 { Name string `json:"name"` // defaults to string Type PluginSettingTypeEnum `json:"type"` // defaults to key name DisplayName string `json:"displayName"` Description string `json:"description"` } type ServerConfig interface { GetHost() string GetPort() int GetConfigPathAbs() string HasTLSConfig() bool GetPluginsPath() string GetDisabledPlugins() []string GetPythonPath() string } // Cache stores plugin details. type Cache struct { config ServerConfig plugins []Config sessionStore *session.Store gqlHandler http.Handler } // NewCache returns a new Cache. // // Plugins configurations are loaded from yml files in the plugin // directory in the config and any subdirectories. // // Does not load plugins. Plugins will need to be // loaded explicitly using ReloadPlugins. func NewCache(config ServerConfig) *Cache { return &Cache{ config: config, } } func (c *Cache) RegisterGQLHandler(handler http.Handler) { c.gqlHandler = handler } func (c *Cache) RegisterSessionStore(sessionStore *session.Store) { c.sessionStore = sessionStore } // ReloadPlugins clears the plugin cache and loads from the plugin path. // If a plugin cannot be loaded, an error is logged and the plugin is skipped. func (c *Cache) ReloadPlugins() { path := c.config.GetPluginsPath() // # 4484 - ensure plugin ids are unique plugins := make([]Config, 0) pluginIDs := make(map[string]bool) logger.Debugf("Reading plugin configs from %s", path) err := fsutil.SymWalk(path, func(fp string, f os.FileInfo, err error) error { if filepath.Ext(fp) == ".yml" { plugin, err := loadPluginFromYAMLFile(fp) // use case insensitive plugin IDs if err != nil { logger.Errorf("Error loading plugin %s: %v", fp, err) } else { pluginID := strings.ToLower(plugin.id) if _, exists := pluginIDs[pluginID]; exists { logger.Errorf("Error loading plugin %s: plugin ID %s already exists", fp, plugin.id) return nil } pluginIDs[pluginID] = true plugins = append(plugins, *plugin) } } return nil }) if err != nil { logger.Errorf("Error reading plugin configs: %v", err) } c.plugins = plugins } func (c Cache) enabledPlugins() []Config { disabledPlugins := c.config.GetDisabledPlugins() var ret []Config for _, p := range c.plugins { disabled := slices.Contains(disabledPlugins, p.id) if !disabled { ret = append(ret, p) } } return ret } func (c Cache) pluginDisabled(id string) bool { disabledPlugins := c.config.GetDisabledPlugins() return slices.Contains(disabledPlugins, id) } // ListPlugins returns plugin details for all of the loaded plugins. func (c Cache) ListPlugins() []*Plugin { disabledPlugins := c.config.GetDisabledPlugins() var ret []*Plugin for _, s := range c.plugins { p := s.toPlugin() disabled := slices.Contains(disabledPlugins, p.ID) p.Enabled = !disabled ret = append(ret, p) } 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 := slices.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 for _, s := range c.enabledPlugins() { ret = append(ret, s.getPluginTasks(true)...) } return ret } func buildPluginInput(plugin *Config, operation *OperationConfig, serverConnection common.StashServerConnection, args OperationInput) common.PluginInput { if args == nil { args = make(OperationInput) } if operation != nil { applyDefaultArgs(args, operation.DefaultArgs) } serverConnection.PluginDir = plugin.getConfigPath() return common.PluginInput{ ServerConnection: serverConnection, Args: toPluginArgs(args), } } func (c Cache) makeServerConnection(ctx context.Context) common.StashServerConnection { cookie := c.sessionStore.MakePluginCookie(ctx) serverConnection := common.StashServerConnection{ Scheme: "http", Host: c.config.GetHost(), Port: c.config.GetPort(), SessionCookie: cookie, Dir: c.config.GetConfigPathAbs(), } if c.config.HasTLSConfig() { serverConnection.Scheme = "https" } return serverConnection } // CreateTask runs the plugin operation for the pluginID and operation // name provided. Returns an error if the plugin or the operation could not be // resolved. func (c Cache) CreateTask(ctx context.Context, pluginID string, operationName *string, args OperationInput, progress chan float64) (Task, error) { serverConnection := c.makeServerConnection(ctx) if c.pluginDisabled(pluginID) { return nil, fmt.Errorf("plugin %s is disabled", pluginID) } // find the plugin and operation plugin := c.getPlugin(pluginID) if plugin == nil { return nil, fmt.Errorf("no plugin with ID %s", pluginID) } var operation *OperationConfig if operationName != nil { operation = plugin.getTask(*operationName) if operation == nil { return nil, fmt.Errorf("no task with name %s in plugin %s", *operationName, plugin.getName()) } } task := pluginTask{ plugin: plugin, operation: operation, input: buildPluginInput(plugin, operation, serverConnection, args), progress: progress, gqlHandler: c.gqlHandler, serverConfig: c.config, } return task.createTask(), nil } func (c Cache) RunPlugin(ctx context.Context, pluginID string, args OperationInput) (interface{}, error) { serverConnection := c.makeServerConnection(ctx) if c.pluginDisabled(pluginID) { return nil, fmt.Errorf("plugin %s is disabled", pluginID) } // find the plugin plugin := c.getPlugin(pluginID) pluginInput := buildPluginInput(plugin, nil, serverConnection, args) pt := pluginTask{ plugin: plugin, input: pluginInput, gqlHandler: c.gqlHandler, serverConfig: c.config, } task := pt.createTask() if err := task.Start(); err != nil { return nil, err } if err := waitForTask(ctx, task); err != nil { return nil, err } output := task.GetResult() if output == nil { logger.Debugf("%s: returned no result", pluginID) return nil, nil } else { if output.Error != nil { return nil, errors.New(*output.Error) } return output.Output, nil } } func waitForTask(ctx context.Context, task Task) error { // handle cancel from context c := make(chan struct{}) go func() { task.Wait() close(c) }() select { case <-ctx.Done(): if err := task.Stop(); err != nil { logger.Warnf("could not stop task: %v", err) } return fmt.Errorf("operation cancelled") case <-c: // task finished normally } return nil } func (c Cache) ExecutePostHooks(ctx context.Context, id int, hookType hook.TriggerEnum, input interface{}, inputFields []string) { if err := c.executePostHooks(ctx, hookType, common.HookContext{ ID: id, Type: hookType.String(), Input: input, InputFields: inputFields, }); err != nil { logger.Errorf("error executing post hooks: %s", err.Error()) } } func (c Cache) RegisterPostHooks(ctx context.Context, id int, hookType hook.TriggerEnum, input interface{}, inputFields []string) { txn.AddPostCommitHook(ctx, func(ctx context.Context) { c.ExecutePostHooks(ctx, id, hookType, input, inputFields) }) } func (c Cache) ExecuteSceneUpdatePostHooks(ctx context.Context, input models.SceneUpdateInput, inputFields []string) { id, err := strconv.Atoi(input.ID) if err != nil { logger.Errorf("error converting id in SceneUpdatePostHooks: %v", err) return } c.ExecutePostHooks(ctx, id, hook.SceneUpdatePost, input, inputFields) } // maxCyclicLoopDepth is the maximum number of identical plugin hook calls that // can be made before a cyclic loop is detected. It is set to an arbitrary value // that should not be hit under normal circumstances. const maxCyclicLoopDepth = 10 func (c Cache) executePostHooks(ctx context.Context, hookType hook.TriggerEnum, hookContext common.HookContext) error { visitedPluginHookCounts := getVisitedPluginHookCounts(ctx) for _, p := range c.enabledPlugins() { hooks := p.getHooks(hookType) // don't revisit a plugin we've already visited // only log if there's hooks that we're skipping if len(hooks) > 0 && visitedPluginHookCounts.For(p.id, hookType) >= maxCyclicLoopDepth { logger.Debugf("cyclic loop detected: plugin ID '%s' hook %s, not re-triggering", p.id, hookType) continue } for _, h := range hooks { newCtx := session.AddVisitedPluginHook(ctx, p.id, hookType) serverConnection := c.makeServerConnection(newCtx) pluginInput := buildPluginInput(&p, &h.OperationConfig, serverConnection, nil) addHookContext(pluginInput.Args, hookContext) pt := pluginTask{ plugin: &p, operation: &h.OperationConfig, input: pluginInput, gqlHandler: c.gqlHandler, serverConfig: c.config, } task := pt.createTask() if err := task.Start(); err != nil { return err } if err := waitForTask(ctx, task); err != nil { return err } output := task.GetResult() if output == nil { logger.Debugf("%s [%s]: returned no result", hookType.String(), p.Name) } else { if output.Error != nil { logger.Errorf("%s [%s]: returned error: %s", hookType.String(), p.Name, *output.Error) } else if output.Output != nil { logger.Debugf("%s [%s]: returned: %v", hookType.String(), p.Name, output.Output) } } } } return nil } type visitedPluginHookCount struct { session.VisitedPluginHook Count int } type visitedPluginHookCounts []visitedPluginHookCount func (v visitedPluginHookCounts) For(pluginID string, hookType hook.TriggerEnum) int { for _, c := range v { if c.VisitedPluginHook.PluginID == pluginID && c.VisitedPluginHook.HookType == hookType { return c.Count } } return 0 } func getVisitedPluginHookCounts(ctx context.Context) visitedPluginHookCounts { visitedPluginHooks := session.GetVisitedPluginHooks(ctx) visitedPluginHookCounts := make([]visitedPluginHookCount, 0) for _, p := range visitedPluginHooks { found := false for i, v := range visitedPluginHookCounts { if v.VisitedPluginHook == p { visitedPluginHookCounts[i].Count++ found = true break } } if !found { visitedPluginHookCounts = append(visitedPluginHookCounts, visitedPluginHookCount{ VisitedPluginHook: p, Count: 1, }) } } return visitedPluginHookCounts } func (c Cache) getPlugin(pluginID string) *Config { for _, s := range c.plugins { if s.id == pluginID { return &s } } return nil }