mattermost-plugin-shelfmark/server/configuration.go
Felipe M. a4ee9379eb
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
ci / plugin-ci (push) Has been cancelled
Validate channel existence and Shelfmark reachability on config change
Add early validation in OnConfigurationChange to verify the configured
channel exists in Mattermost and the Shelfmark server is reachable,
giving admins immediate feedback instead of discovering issues at runtime.

- Add Ping() method to shelfmark.Client for lightweight reachability check
- Add validateConfiguration() on Plugin that checks channel + Shelfmark
- Add i18n messages for channel not found and Shelfmark unreachable
- Add unit tests for Ping and validateConfiguration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 17:55:08 +01:00

174 lines
5.9 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
}
// 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
}