package main import ( "fmt" "reflect" "strings" "github.com/pkg/errors" "git.nakama.town/fmartingr/mattermost-plugin-shelfmark/server/shelfmark" ) // configuration captures the plugin's external configuration as exposed in the Mattermost server // configuration, as well as values computed from the configuration. Any public fields will be // deserialized from the Mattermost server configuration in OnConfigurationChange. type configuration struct { // ShelfmarkHost is the base URL of the Shelfmark server (e.g., http://shelfmark:8084). ShelfmarkHost string `json:"ShelfmarkHost"` // ShelfmarkUsername is the username used to authenticate with the Shelfmark server. ShelfmarkUsername string `json:"ShelfmarkUsername"` // ShelfmarkPassword is the password used to authenticate with the Shelfmark server. ShelfmarkPassword string `json:"ShelfmarkPassword"` // TeamID restricts the plugin to a specific team. Empty means all teams. TeamID string `json:"TeamID"` // ChannelID is the Mattermost channel where book posts will be created. ChannelID string `json:"ChannelID"` // DefaultLanguage is the ISO language code used when no --language flag is specified. // Leave empty for no language filtering. DefaultLanguage string `json:"DefaultLanguage"` // PostTemplate is a Go text/template string used to format the book post message. PostTemplate string `json:"PostTemplate"` } // Clone shallow copies the configuration. func (c *configuration) Clone() *configuration { clone := *c return &clone } // IsValid checks that required configuration fields are set. func (c *configuration) IsValid() error { host := strings.TrimSpace(c.ShelfmarkHost) if host == "" { return fmt.Errorf("shelfmark server URL must be configured") } if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") { return fmt.Errorf("shelfmark server URL must start with http:// or https://") } if strings.TrimSpace(c.ChannelID) == "" { return fmt.Errorf("channel ID must be configured") } return nil } // validateShelfmarkHost checks that the Shelfmark server URL is configured. func (c *configuration) validateShelfmarkHost() error { host := strings.TrimSpace(c.ShelfmarkHost) if host == "" { return fmt.Errorf("shelfmark server URL must be configured") } if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") { return fmt.Errorf("shelfmark server URL must start with http:// or https://") } return nil } // testShelfmarkConnection verifies connectivity to the Shelfmark server using // the saved configuration. func (c *configuration) testShelfmarkConnection() error { if err := c.validateShelfmarkHost(); err != nil { return err } host, username, password := c.shelfmarkCredentials() client := shelfmark.NewClient(host, username, password) if err := client.TestConnection(); err != nil { return fmt.Errorf("%s: %w", T("en", MsgShelfmarkUnreachable), err) } return nil } // shelfmarkCredentials returns the trimmed Shelfmark connection credentials. func (c *configuration) shelfmarkCredentials() (host, username, password string) { return strings.TrimSpace(c.ShelfmarkHost), strings.TrimSpace(c.ShelfmarkUsername), strings.TrimSpace(c.ShelfmarkPassword) } // getTeamID returns the configured team ID restriction, trimmed. // Returns empty string if no team restriction is set. func (c *configuration) getTeamID() string { return strings.TrimSpace(c.TeamID) } // getDefaultLanguage returns the configured default language, trimmed. // Returns empty string if no default is set (meaning no language filtering). func (c *configuration) getDefaultLanguage() string { return strings.TrimSpace(c.DefaultLanguage) } // getPostTemplate returns the configured post template or the default. func (c *configuration) getPostTemplate() string { if strings.TrimSpace(c.PostTemplate) == "" { return "### {{.Title}}" } return c.PostTemplate } // validateConfiguration performs full validation of the configuration, including // verifying that the configured channel exists and that the Shelfmark server is reachable. func (p *Plugin) validateConfiguration(config *configuration) error { if err := config.IsValid(); err != nil { return err } // Verify the channel exists in Mattermost. if _, appErr := p.API.GetChannel(config.ChannelID); appErr != nil { return fmt.Errorf("%s: %s", T("en", MsgChannelNotFound), appErr.Error()) } // Verify the Shelfmark server is reachable. host, username, password := config.shelfmarkCredentials() client := shelfmark.NewClient(host, username, password) if err := client.Ping(); err != nil { return fmt.Errorf("%s: %w", T("en", MsgShelfmarkUnreachable), err) } return nil } // getConfiguration retrieves the active configuration under lock, making it safe to use // concurrently. The active configuration may change underneath the client of this method, but // the struct returned by this API call is considered immutable. func (p *Plugin) getConfiguration() *configuration { p.configurationLock.RLock() defer p.configurationLock.RUnlock() if p.configuration == nil { return &configuration{} } return p.configuration } // setConfiguration replaces the active configuration under lock. // // Do not call setConfiguration while holding the configurationLock, as sync.Mutex is not // reentrant. In particular, avoid using the plugin API entirely, as this may in turn trigger a // hook back into the plugin. If that hook attempts to acquire this lock, a deadlock may occur. // // This method panics if setConfiguration is called with the existing configuration. This almost // certainly means that the configuration was modified without being cloned and may result in // an unsafe access. func (p *Plugin) setConfiguration(configuration *configuration) { p.configurationLock.Lock() defer p.configurationLock.Unlock() if configuration != nil && p.configuration == configuration { if reflect.ValueOf(*configuration).NumField() == 0 { return } panic("setConfiguration called with the existing configuration") } p.configuration = configuration } // OnConfigurationChange is invoked when configuration changes may have been made. func (p *Plugin) OnConfigurationChange() error { configuration := new(configuration) // Load the public configuration fields from the Mattermost server configuration. if err := p.API.LoadPluginConfiguration(configuration); err != nil { return errors.Wrap(err, "failed to load plugin configuration") } p.setConfiguration(configuration) // Warn if the new configuration is invalid (but don't block — Mattermost // calls OnConfigurationChange before OnActivate, so partial config is expected). if err := p.validateConfiguration(configuration); err != nil { if p.API != nil { p.API.LogWarn("Plugin configuration is invalid", "error", err.Error()) } } // Notify the plugin that configuration has changed so it can update clients. p.onConfigurationChanged() return nil }