mattermost-plugin-shelfmark/server/configuration.go
Felipe M. 620d601091
All checks were successful
ci / lint (push) Successful in 4m44s
ci / test (push) Successful in 4m0s
ci / build (push) Successful in 7m17s
release / release (push) Successful in 7m19s
Add System Console button to test Shelfmark connection.
Expose an admin-only endpoint that validates the saved plugin configuration against the Shelfmark server, and surface it in the plugin settings UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 10:39:10 +02:00

204 lines
6.8 KiB
Go

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
}