mirror of
https://github.com/mattermost/mattermost.git
synced 2025-02-25 18:55:24 -06:00
MM-13893: introduce file store (#10243)
* config file store Introduce an interface and concrete implementation for accessing the config. This mostly maps 1:1 with the exiting usage in `App`, except for internalizing the watcher. A future change will likely eliminate `App.PersistConfig()` and make this implicit on `Set` or `Patch` * experimental file test changes * emoji: move file driver checks from api4 to app It is no longer possible to app.UpdateConfig and provide an invalid configuration, making it hard to test this case. This check doesn't really belong in the api anyway, since it's a configuration validity check and not a permissions check. Either way, the check now occurs at the App level. * api4: generate valid public link salts for test * TestStartServerRateLimiterCriticalError: use mock store to test invalid config * remove config_test.go * remove needsSave, and have Load() save to the backing store as necessary * restore README.md * move ldap UserFilter check to model isValid checks * remove databaseStore until ready * remove unimplemented Patch * simplify unlockOnce implementation * revert forgetting to set s.Ldap * config/file.go: rename ReadOnlyConfigurationError to ErrReadOnlyConfiguration * config: export FileStore * add TestFileStoreSave * improved config/utils test coverage * restore config/README.md copy * tweaks * file store: acquire a write lock on Save/Close to safely close watcher * fix unmarshal_test.go
This commit is contained in:
committed by
Christopher Speller
parent
9cfcab2307
commit
285b646d67
@@ -37,11 +37,6 @@ func createEmoji(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(*c.App.Config().FileSettings.DriverName) == 0 {
|
||||
c.Err = model.NewAppError("createEmoji", "api.emoji.storage.app_error", nil, "", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
if r.ContentLength > app.MaxEmojiFileSize {
|
||||
c.Err = model.NewAppError("createEmoji", "api.emoji.create.too_large.app_error", nil, "", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
@@ -224,11 +219,6 @@ func getEmojiImage(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(*c.App.Config().FileSettings.DriverName) == 0 {
|
||||
c.Err = model.NewAppError("getEmojiImage", "api.emoji.storage.app_error", nil, "", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
image, imageType, err := c.App.GetEmojiImage(c.Params.EmojiId)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
|
||||
@@ -486,12 +486,6 @@ func TestGetEmojiImage(t *testing.T) {
|
||||
defer th.TearDown()
|
||||
Client := th.Client
|
||||
|
||||
EnableCustomEmoji := *th.App.Config().ServiceSettings.EnableCustomEmoji
|
||||
DriverName := *th.App.Config().FileSettings.DriverName
|
||||
defer func() {
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = EnableCustomEmoji })
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.DriverName = DriverName })
|
||||
}()
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = true })
|
||||
|
||||
emoji1 := &model.Emoji{
|
||||
@@ -508,14 +502,8 @@ func TestGetEmojiImage(t *testing.T) {
|
||||
CheckNotImplementedStatus(t, resp)
|
||||
CheckErrorMessage(t, resp, "api.emoji.disabled.app_error")
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.DriverName = "" })
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = true })
|
||||
|
||||
_, resp = Client.GetEmojiImage(emoji1.Id)
|
||||
CheckNotImplementedStatus(t, resp)
|
||||
CheckErrorMessage(t, resp, "api.emoji.storage.app_error")
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.DriverName = DriverName })
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.DriverName = "local" })
|
||||
|
||||
emojiImage, resp := Client.GetEmojiImage(emoji1.Id)
|
||||
CheckNoError(t, resp)
|
||||
|
||||
@@ -928,14 +928,8 @@ func TestGetFileLink(t *testing.T) {
|
||||
t.Skip("skipping because no file driver is enabled")
|
||||
}
|
||||
|
||||
enablePublicLink := th.App.Config().FileSettings.EnablePublicLink
|
||||
publicLinkSalt := *th.App.Config().FileSettings.PublicLinkSalt
|
||||
defer func() {
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FileSettings.EnablePublicLink = enablePublicLink })
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.PublicLinkSalt = publicLinkSalt })
|
||||
}()
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.EnablePublicLink = true })
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.PublicLinkSalt = model.NewId() })
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.PublicLinkSalt = model.NewRandomString(32) })
|
||||
|
||||
fileId := ""
|
||||
if data, err := testutils.ReadTestFile("test.png"); err != nil {
|
||||
@@ -1119,18 +1113,8 @@ func TestGetPublicFile(t *testing.T) {
|
||||
Client := th.Client
|
||||
channel := th.BasicChannel
|
||||
|
||||
if *th.App.Config().FileSettings.DriverName == "" {
|
||||
t.Skip("skipping because no file driver is enabled")
|
||||
}
|
||||
|
||||
enablePublicLink := th.App.Config().FileSettings.EnablePublicLink
|
||||
publicLinkSalt := *th.App.Config().FileSettings.PublicLinkSalt
|
||||
defer func() {
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FileSettings.EnablePublicLink = enablePublicLink })
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.PublicLinkSalt = publicLinkSalt })
|
||||
}()
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.EnablePublicLink = true })
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.PublicLinkSalt = GenerateTestId() })
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.PublicLinkSalt = model.NewRandomString(32) })
|
||||
|
||||
fileId := ""
|
||||
if data, err := testutils.ReadTestFile("test.png"); err != nil {
|
||||
@@ -1168,7 +1152,7 @@ func TestGetPublicFile(t *testing.T) {
|
||||
|
||||
// test after the salt has changed
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.EnablePublicLink = true })
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.PublicLinkSalt = GenerateTestId() })
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.PublicLinkSalt = model.NewRandomString(32) })
|
||||
|
||||
if resp, err := http.Get(link); err == nil && resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatal("should've failed to get image with public link after salt changed")
|
||||
|
||||
41
app/admin.go
41
app/admin.go
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
"net/http"
|
||||
|
||||
"github.com/mattermost/mattermost-server/einterfaces"
|
||||
"github.com/mattermost/mattermost-server/config"
|
||||
"github.com/mattermost/mattermost-server/mlog"
|
||||
"github.com/mattermost/mattermost-server/model"
|
||||
"github.com/mattermost/mattermost-server/services/mailservice"
|
||||
@@ -160,40 +160,15 @@ func (a *App) GetEnvironmentConfig() map[string]interface{} {
|
||||
return a.EnvironmentConfig()
|
||||
}
|
||||
|
||||
func validateLdapFilter(cfg *model.Config, ldap einterfaces.LdapInterface) *model.AppError {
|
||||
if !*cfg.LdapSettings.Enable || ldap == nil || *cfg.LdapSettings.UserFilter == "" {
|
||||
return nil
|
||||
func (a *App) SaveConfig(newCfg *model.Config, sendConfigChangeClusterMessage bool) *model.AppError {
|
||||
oldCfg, err := a.Srv.configStore.Set(newCfg)
|
||||
if err == config.ErrReadOnlyConfiguration {
|
||||
return model.NewAppError("saveConfig", "ent.cluster.save_config.error", nil, err.Error(), http.StatusForbidden)
|
||||
} else if err != nil {
|
||||
return model.NewAppError("saveConfig", "app.save_config.app_error", nil, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
return ldap.ValidateFilter(*cfg.LdapSettings.UserFilter)
|
||||
}
|
||||
|
||||
func (a *App) SaveConfig(cfg *model.Config, sendConfigChangeClusterMessage bool) *model.AppError {
|
||||
oldCfg := a.Config()
|
||||
cfg.SetDefaults()
|
||||
a.Desanitize(cfg)
|
||||
|
||||
if err := cfg.IsValid(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateLdapFilter(cfg, a.Ldap); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *a.Config().ClusterSettings.Enable && *a.Config().ClusterSettings.ReadOnlyConfig {
|
||||
return model.NewAppError("saveConfig", "ent.cluster.save_config.error", nil, "", http.StatusForbidden)
|
||||
}
|
||||
|
||||
a.DisableConfigWatch()
|
||||
|
||||
a.UpdateConfig(func(update *model.Config) {
|
||||
*update = *cfg
|
||||
})
|
||||
a.PersistConfig()
|
||||
a.ReloadConfig()
|
||||
a.EnableConfigWatch()
|
||||
|
||||
if a.Metrics != nil {
|
||||
if *a.Config().MetricsSettings.Enable {
|
||||
a.Metrics.StartServer()
|
||||
@@ -203,7 +178,7 @@ func (a *App) SaveConfig(cfg *model.Config, sendConfigChangeClusterMessage bool)
|
||||
}
|
||||
|
||||
if a.Cluster != nil {
|
||||
err := a.Cluster.ConfigChanged(cfg, oldCfg, sendConfigChangeClusterMessage)
|
||||
err := a.Cluster.ConfigChanged(newCfg, oldCfg, sendConfigChangeClusterMessage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
134
app/config.go
134
app/config.go
@@ -15,7 +15,6 @@ import (
|
||||
"net/url"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost-server/config"
|
||||
@@ -29,10 +28,7 @@ const (
|
||||
)
|
||||
|
||||
func (s *Server) Config() *model.Config {
|
||||
if cfg := s.config.Load(); cfg != nil {
|
||||
return cfg.(*model.Config)
|
||||
}
|
||||
return &model.Config{}
|
||||
return s.configStore.Get()
|
||||
}
|
||||
|
||||
func (a *App) Config() *model.Config {
|
||||
@@ -40,10 +36,7 @@ func (a *App) Config() *model.Config {
|
||||
}
|
||||
|
||||
func (s *Server) EnvironmentConfig() map[string]interface{} {
|
||||
if s.envConfig != nil {
|
||||
return s.envConfig
|
||||
}
|
||||
return map[string]interface{}{}
|
||||
return s.configStore.GetEnvironmentOverrides()
|
||||
}
|
||||
|
||||
func (a *App) EnvironmentConfig() map[string]interface{} {
|
||||
@@ -54,9 +47,9 @@ func (s *Server) UpdateConfig(f func(*model.Config)) {
|
||||
old := s.Config()
|
||||
updated := old.Clone()
|
||||
f(updated)
|
||||
s.config.Store(updated)
|
||||
|
||||
s.InvokeConfigListeners(old, updated)
|
||||
if _, err := s.configStore.Set(updated); err != nil {
|
||||
mlog.Error("Failed to update config", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) UpdateConfig(f func(*model.Config)) {
|
||||
@@ -64,46 +57,23 @@ func (a *App) UpdateConfig(f func(*model.Config)) {
|
||||
}
|
||||
|
||||
func (a *App) PersistConfig() {
|
||||
config.SaveConfig(a.ConfigFileName(), a.Config())
|
||||
}
|
||||
|
||||
func (s *Server) LoadConfig(configFile string) *model.AppError {
|
||||
old := s.Config()
|
||||
|
||||
cfg, configPath, envConfig, err := config.LoadConfig(configFile)
|
||||
if err != nil {
|
||||
return err
|
||||
if err := a.Srv.configStore.Save(); err != nil {
|
||||
mlog.Error("Failed to persist config", mlog.Err(err))
|
||||
}
|
||||
*cfg.ServiceSettings.SiteURL = strings.TrimRight(*cfg.ServiceSettings.SiteURL, "/")
|
||||
s.config.Store(cfg)
|
||||
|
||||
s.configFile = configPath
|
||||
s.envConfig = envConfig
|
||||
|
||||
s.InvokeConfigListeners(old, cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) LoadConfig(configFile string) *model.AppError {
|
||||
return a.Srv.LoadConfig(configFile)
|
||||
}
|
||||
|
||||
func (s *Server) ReloadConfig() *model.AppError {
|
||||
func (s *Server) ReloadConfig() error {
|
||||
debug.FreeOSMemory()
|
||||
if err := s.LoadConfig(s.configFile); err != nil {
|
||||
if err := s.configStore.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ReloadConfig() *model.AppError {
|
||||
func (a *App) ReloadConfig() error {
|
||||
return a.Srv.ReloadConfig()
|
||||
}
|
||||
|
||||
func (a *App) ConfigFileName() string {
|
||||
return a.Srv.configFile
|
||||
}
|
||||
|
||||
func (a *App) ClientConfig() map[string]string {
|
||||
return a.Srv.clientConfig
|
||||
}
|
||||
@@ -116,40 +86,11 @@ func (a *App) LimitedClientConfig() map[string]string {
|
||||
return a.Srv.limitedClientConfig
|
||||
}
|
||||
|
||||
func (s *Server) EnableConfigWatch() {
|
||||
if s.configWatcher == nil && !s.disableConfigWatch {
|
||||
configWatcher, err := config.NewConfigWatcher(s.configFile, func() {
|
||||
s.ReloadConfig()
|
||||
})
|
||||
if err != nil {
|
||||
mlog.Error(fmt.Sprint(err))
|
||||
}
|
||||
s.configWatcher = configWatcher
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) EnableConfigWatch() {
|
||||
a.Srv.EnableConfigWatch()
|
||||
}
|
||||
|
||||
func (s *Server) DisableConfigWatch() {
|
||||
if s.configWatcher != nil {
|
||||
s.configWatcher.Close()
|
||||
s.configWatcher = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) DisableConfigWatch() {
|
||||
a.Srv.DisableConfigWatch()
|
||||
}
|
||||
|
||||
// Registers a function with a given to be called when the config is reloaded and may have changed. The function
|
||||
// will be called with two arguments: the old config and the new config. AddConfigListener returns a unique ID
|
||||
// for the listener that can later be used to remove it.
|
||||
func (s *Server) AddConfigListener(listener func(*model.Config, *model.Config)) string {
|
||||
id := model.NewId()
|
||||
s.configListeners[id] = listener
|
||||
return id
|
||||
return s.configStore.AddListener(listener)
|
||||
}
|
||||
|
||||
func (a *App) AddConfigListener(listener func(*model.Config, *model.Config)) string {
|
||||
@@ -158,19 +99,13 @@ func (a *App) AddConfigListener(listener func(*model.Config, *model.Config)) str
|
||||
|
||||
// Removes a listener function by the unique ID returned when AddConfigListener was called
|
||||
func (s *Server) RemoveConfigListener(id string) {
|
||||
delete(s.configListeners, id)
|
||||
s.configStore.RemoveListener(id)
|
||||
}
|
||||
|
||||
func (a *App) RemoveConfigListener(id string) {
|
||||
a.Srv.RemoveConfigListener(id)
|
||||
}
|
||||
|
||||
func (s *Server) InvokeConfigListeners(old, current *model.Config) {
|
||||
for _, listener := range s.configListeners {
|
||||
listener(old, current)
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureAsymmetricSigningKey ensures that an asymmetric signing key exists and future calls to
|
||||
// AsymmetricSigningKey will always return a valid signing key.
|
||||
func (a *App) ensureAsymmetricSigningKey() error {
|
||||
@@ -306,51 +241,6 @@ func (a *App) regenerateClientConfig() {
|
||||
a.Srv.clientConfigHash = fmt.Sprintf("%x", md5.Sum(clientConfigJSON))
|
||||
}
|
||||
|
||||
func (a *App) Desanitize(cfg *model.Config) {
|
||||
actual := a.Config()
|
||||
|
||||
if cfg.LdapSettings.BindPassword != nil && *cfg.LdapSettings.BindPassword == model.FAKE_SETTING {
|
||||
*cfg.LdapSettings.BindPassword = *actual.LdapSettings.BindPassword
|
||||
}
|
||||
|
||||
if *cfg.FileSettings.PublicLinkSalt == model.FAKE_SETTING {
|
||||
*cfg.FileSettings.PublicLinkSalt = *actual.FileSettings.PublicLinkSalt
|
||||
}
|
||||
if *cfg.FileSettings.AmazonS3SecretAccessKey == model.FAKE_SETTING {
|
||||
cfg.FileSettings.AmazonS3SecretAccessKey = actual.FileSettings.AmazonS3SecretAccessKey
|
||||
}
|
||||
|
||||
if *cfg.EmailSettings.InviteSalt == model.FAKE_SETTING {
|
||||
cfg.EmailSettings.InviteSalt = actual.EmailSettings.InviteSalt
|
||||
}
|
||||
if *cfg.EmailSettings.SMTPPassword == model.FAKE_SETTING {
|
||||
cfg.EmailSettings.SMTPPassword = actual.EmailSettings.SMTPPassword
|
||||
}
|
||||
|
||||
if *cfg.GitLabSettings.Secret == model.FAKE_SETTING {
|
||||
*cfg.GitLabSettings.Secret = *actual.GitLabSettings.Secret
|
||||
}
|
||||
|
||||
if *cfg.SqlSettings.DataSource == model.FAKE_SETTING {
|
||||
*cfg.SqlSettings.DataSource = *actual.SqlSettings.DataSource
|
||||
}
|
||||
if *cfg.SqlSettings.AtRestEncryptKey == model.FAKE_SETTING {
|
||||
cfg.SqlSettings.AtRestEncryptKey = actual.SqlSettings.AtRestEncryptKey
|
||||
}
|
||||
|
||||
if *cfg.ElasticsearchSettings.Password == model.FAKE_SETTING {
|
||||
*cfg.ElasticsearchSettings.Password = *actual.ElasticsearchSettings.Password
|
||||
}
|
||||
|
||||
for i := range cfg.SqlSettings.DataSourceReplicas {
|
||||
cfg.SqlSettings.DataSourceReplicas[i] = actual.SqlSettings.DataSourceReplicas[i]
|
||||
}
|
||||
|
||||
for i := range cfg.SqlSettings.DataSourceSearchReplicas {
|
||||
cfg.SqlSettings.DataSourceSearchReplicas[i] = actual.SqlSettings.DataSourceSearchReplicas[i]
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) GetCookieDomain() string {
|
||||
if *a.Config().ServiceSettings.AllowCookiesForSubdomains {
|
||||
if siteURL, err := url.Parse(*a.Config().ServiceSettings.SiteURL); err == nil {
|
||||
|
||||
@@ -4,67 +4,29 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/model"
|
||||
"github.com/mattermost/mattermost-server/store/sqlstore"
|
||||
"github.com/mattermost/mattermost-server/utils"
|
||||
"github.com/mattermost/mattermost-server/utils/fileutils"
|
||||
)
|
||||
|
||||
func TestLoadConfig(t *testing.T) {
|
||||
tempConfig, err := ioutil.TempFile("", "")
|
||||
require.Nil(t, err)
|
||||
|
||||
input, err := ioutil.ReadFile(fileutils.FindConfigFile("config.json"))
|
||||
require.Nil(t, err)
|
||||
lines := strings.Split(string(input), "\n")
|
||||
for i, line := range lines {
|
||||
if strings.Contains(line, "SiteURL") {
|
||||
lines[i] = ` "SiteURL": "http://localhost:8065/",`
|
||||
}
|
||||
}
|
||||
output := strings.Join(lines, "\n")
|
||||
err = ioutil.WriteFile(tempConfig.Name(), []byte(output), 0644)
|
||||
require.Nil(t, err)
|
||||
tempConfig.Close()
|
||||
|
||||
a := App{
|
||||
Srv: &Server{},
|
||||
}
|
||||
appErr := a.LoadConfig(tempConfig.Name())
|
||||
require.Nil(t, appErr)
|
||||
|
||||
assert.Equal(t, "http://localhost:8065", *a.Config().ServiceSettings.SiteURL)
|
||||
}
|
||||
|
||||
func TestConfigListener(t *testing.T) {
|
||||
th := Setup().InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
originalSiteName := th.App.Config().TeamSettings.SiteName
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.TeamSettings.SiteName = "test123"
|
||||
})
|
||||
|
||||
listenerCalled := false
|
||||
listener := func(oldConfig *model.Config, newConfig *model.Config) {
|
||||
if listenerCalled {
|
||||
t.Fatal("listener called twice")
|
||||
}
|
||||
assert.False(t, listenerCalled, "listener called twice")
|
||||
|
||||
if *oldConfig.TeamSettings.SiteName != "test123" {
|
||||
t.Fatal("old config contains incorrect site name")
|
||||
} else if *newConfig.TeamSettings.SiteName != *originalSiteName {
|
||||
t.Fatal("new config contains incorrect site name")
|
||||
}
|
||||
assert.Equal(t, *originalSiteName, *oldConfig.TeamSettings.SiteName, "old config contains incorrect site name")
|
||||
assert.Equal(t, "test123", *newConfig.TeamSettings.SiteName, "new config contains incorrect site name")
|
||||
|
||||
listenerCalled = true
|
||||
}
|
||||
@@ -73,22 +35,19 @@ func TestConfigListener(t *testing.T) {
|
||||
|
||||
listener2Called := false
|
||||
listener2 := func(oldConfig *model.Config, newConfig *model.Config) {
|
||||
if listener2Called {
|
||||
t.Fatal("listener2 called twice")
|
||||
}
|
||||
assert.False(t, listener2Called, "listener2 called twice")
|
||||
|
||||
listener2Called = true
|
||||
}
|
||||
listener2Id := th.App.AddConfigListener(listener2)
|
||||
defer th.App.RemoveConfigListener(listener2Id)
|
||||
|
||||
th.App.ReloadConfig()
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.TeamSettings.SiteName = "test123"
|
||||
})
|
||||
|
||||
if !listenerCalled {
|
||||
t.Fatal("listener should've been called")
|
||||
} else if !listener2Called {
|
||||
t.Fatal("listener 2 should've been called")
|
||||
}
|
||||
assert.True(t, listenerCalled, "listener should've been called")
|
||||
assert.True(t, listener2Called, "listener 2 should've been called")
|
||||
}
|
||||
|
||||
func TestAsymmetricSigningKey(t *testing.T) {
|
||||
|
||||
16
app/emoji.go
16
app/emoji.go
@@ -31,6 +31,14 @@ const (
|
||||
)
|
||||
|
||||
func (a *App) CreateEmoji(sessionUserId string, emoji *model.Emoji, multiPartImageData *multipart.Form) (*model.Emoji, *model.AppError) {
|
||||
if !*a.Config().ServiceSettings.EnableCustomEmoji {
|
||||
return nil, model.NewAppError("UploadEmojiImage", "api.emoji.disabled.app_error", nil, "", http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
if len(*a.Config().FileSettings.DriverName) == 0 {
|
||||
return nil, model.NewAppError("GetEmoji", "api.emoji.storage.app_error", nil, "", http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// wipe the emoji id so that existing emojis can't get overwritten
|
||||
emoji.Id = ""
|
||||
|
||||
@@ -79,6 +87,14 @@ func (a *App) GetEmojiList(page, perPage int, sort string) ([]*model.Emoji, *mod
|
||||
}
|
||||
|
||||
func (a *App) UploadEmojiImage(id string, imageData *multipart.FileHeader) *model.AppError {
|
||||
if !*a.Config().ServiceSettings.EnableCustomEmoji {
|
||||
return model.NewAppError("UploadEmojiImage", "api.emoji.disabled.app_error", nil, "", http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
if len(*a.Config().FileSettings.DriverName) == 0 {
|
||||
return model.NewAppError("UploadEmojiImage", "api.emoji.storage.app_error", nil, "", http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
file, err := imageData.Open()
|
||||
if err != nil {
|
||||
return model.NewAppError("uploadEmojiImage", "api.emoji.upload.open.app_error", nil, "", http.StatusBadRequest)
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
ejobs "github.com/mattermost/mattermost-server/einterfaces/jobs"
|
||||
tjobs "github.com/mattermost/mattermost-server/jobs/interfaces"
|
||||
"github.com/mattermost/mattermost-server/model"
|
||||
"github.com/mattermost/mattermost-server/utils"
|
||||
)
|
||||
|
||||
var accountMigrationInterface func(*App) einterfaces.AccountMigrationInterface
|
||||
@@ -119,11 +118,6 @@ func (s *Server) initEnterprise() {
|
||||
}
|
||||
if ldapInterface != nil {
|
||||
s.Ldap = ldapInterface(s.FakeApp())
|
||||
s.AddConfigListener(func(_, cfg *model.Config) {
|
||||
if err := validateLdapFilter(cfg, s.Ldap); err != nil {
|
||||
panic(utils.T(err.Id))
|
||||
}
|
||||
})
|
||||
}
|
||||
if messageExportInterface != nil {
|
||||
s.MessageExport = messageExportInterface(s.FakeApp())
|
||||
|
||||
@@ -6,6 +6,7 @@ package app
|
||||
import (
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/mattermost/mattermost-server/config"
|
||||
"github.com/mattermost/mattermost-server/store"
|
||||
)
|
||||
|
||||
@@ -38,8 +39,19 @@ func StoreOverride(override interface{}) Option {
|
||||
|
||||
func ConfigFile(file string, watch bool) Option {
|
||||
return func(s *Server) error {
|
||||
s.configFile = file
|
||||
s.disableConfigWatch = !watch
|
||||
configStore, err := config.NewFileStore(file, watch)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to apply ConfigFile option")
|
||||
}
|
||||
|
||||
s.configStore = configStore
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func ConfigStore(configStore config.Store) Option {
|
||||
return func(s *Server) error {
|
||||
s.configStore = configStore
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -533,8 +532,7 @@ func TestMaxPostSize(t *testing.T) {
|
||||
|
||||
app := App{
|
||||
Srv: &Server{
|
||||
Store: mockStore,
|
||||
config: atomic.Value{},
|
||||
Store: mockStore,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ import (
|
||||
"github.com/mattermost/mattermost-server/services/timezones"
|
||||
"github.com/mattermost/mattermost-server/store"
|
||||
"github.com/mattermost/mattermost-server/utils"
|
||||
"github.com/mattermost/mattermost-server/utils/fileutils"
|
||||
)
|
||||
|
||||
var MaxNotificationsPerChannelDefault int64 = 1000000
|
||||
@@ -75,10 +74,6 @@ type Server struct {
|
||||
runjobs bool
|
||||
Jobs *jobs.JobServer
|
||||
|
||||
config atomic.Value
|
||||
envConfig map[string]interface{}
|
||||
configFile string
|
||||
configListeners map[string]func(*model.Config, *model.Config)
|
||||
clusterLeaderListeners sync.Map
|
||||
|
||||
licenseValue atomic.Value
|
||||
@@ -96,8 +91,7 @@ type Server struct {
|
||||
licenseListenerId string
|
||||
logListenerId string
|
||||
clusterLeaderListenerId string
|
||||
disableConfigWatch bool
|
||||
configWatcher *config.ConfigWatcher
|
||||
configStore config.Store
|
||||
asymmetricSigningKey *ecdsa.PrivateKey
|
||||
|
||||
pluginCommands []*PluginCommand
|
||||
@@ -137,8 +131,6 @@ func NewServer(options ...Option) (*Server, error) {
|
||||
s := &Server{
|
||||
goroutineExitSignal: make(chan struct{}, 1),
|
||||
RootRouter: rootRouter,
|
||||
configFile: "config.json",
|
||||
configListeners: make(map[string]func(*model.Config, *model.Config)),
|
||||
licenseListeners: map[string]func(){},
|
||||
sessionCache: utils.NewLru(model.SESSION_CACHE_SIZE),
|
||||
seenPendingPostIdsCache: utils.NewLru(PENDING_POST_IDS_CACHE_SIZE),
|
||||
@@ -150,11 +142,14 @@ func NewServer(options ...Option) (*Server, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.LoadConfig(s.configFile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.configStore == nil {
|
||||
configStore, err := config.NewFileStore("config.json", true)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to load config")
|
||||
}
|
||||
|
||||
s.EnableConfigWatch()
|
||||
s.configStore = configStore
|
||||
}
|
||||
|
||||
// Initalize logging
|
||||
s.Log = mlog.NewLogger(utils.MloggerConfigFromLoggerConfig(&s.Config().LogSettings))
|
||||
@@ -198,7 +193,7 @@ func NewServer(options ...Option) (*Server, error) {
|
||||
mlog.Info(fmt.Sprintf("Enterprise Enabled: %v", model.BuildEnterpriseReady))
|
||||
pwd, _ := os.Getwd()
|
||||
mlog.Info(fmt.Sprintf("Current working directory is %v", pwd))
|
||||
mlog.Info(fmt.Sprintf("Loaded config file from %v", fileutils.FindConfigFile(s.configFile)))
|
||||
mlog.Info("Loaded config", mlog.String("source", s.configStore.String()))
|
||||
|
||||
license := s.License()
|
||||
|
||||
@@ -323,7 +318,7 @@ func (s *Server) Shutdown() error {
|
||||
s.RemoveConfigListener(s.configListenerId)
|
||||
s.RemoveConfigListener(s.logListenerId)
|
||||
|
||||
s.DisableConfigWatch()
|
||||
s.configStore.Close()
|
||||
|
||||
if s.Cluster != nil {
|
||||
s.Cluster.StopInterNodeCommunication()
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost-server/config"
|
||||
"github.com/mattermost/mattermost-server/model"
|
||||
"github.com/mattermost/mattermost-server/utils/fileutils"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -32,14 +33,14 @@ func TestStartServerSuccess(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStartServerRateLimiterCriticalError(t *testing.T) {
|
||||
s, err := NewServer()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Attempt to use Rate Limiter with an invalid config
|
||||
s.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.RateLimitSettings.Enable = true
|
||||
*cfg.RateLimitSettings.MaxBurst = -100
|
||||
})
|
||||
ms, err := config.NewMemoryStore(true)
|
||||
require.NoError(t, err)
|
||||
*ms.Config.RateLimitSettings.Enable = true
|
||||
*ms.Config.RateLimitSettings.MaxBurst = -100
|
||||
|
||||
s, err := NewServer(ConfigStore(ms))
|
||||
require.NoError(t, err)
|
||||
|
||||
serverErr := s.Start()
|
||||
s.Shutdown()
|
||||
|
||||
@@ -167,24 +167,18 @@ func configShowCmdF(command *cobra.Command, args []string) error {
|
||||
}
|
||||
defer app.Shutdown()
|
||||
|
||||
// check that no arguments are given
|
||||
err = cobra.NoArgs(command, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// set up the config object
|
||||
config := app.Config()
|
||||
|
||||
// pretty print
|
||||
fmt.Printf("%s", prettyPrintStruct(*config))
|
||||
fmt.Printf("%s", prettyPrintStruct(*app.Config()))
|
||||
return nil
|
||||
}
|
||||
|
||||
// printConfigValues function prints out the value of the configSettings working recursively or
|
||||
// gives an error if config setting is not in the file.
|
||||
func printConfigValues(configMap map[string]interface{}, configSetting []string, name string) (string, error) {
|
||||
|
||||
res, ok := configMap[configSetting[0]]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s configuration setting is not in the file", name)
|
||||
@@ -217,37 +211,26 @@ func configSetCmdF(command *cobra.Command, args []string) error {
|
||||
configSetting := args[0]
|
||||
newVal := args[1:]
|
||||
|
||||
// Update the config
|
||||
|
||||
// first disable the watchers
|
||||
app.DisableConfigWatch()
|
||||
|
||||
// create the function to update config
|
||||
oldConfig := app.Config()
|
||||
newConfig := app.Config()
|
||||
f := updateConfigValue(configSetting, newVal, oldConfig, newConfig)
|
||||
|
||||
// update the config
|
||||
app.UpdateConfig(f)
|
||||
|
||||
// Verify new config
|
||||
if err := newConfig.IsValid(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := config.ValidateLocales(app.Config()); err != nil {
|
||||
// UpdateConfig above would have already fixed these invalid locales, but we check again
|
||||
// in the context of an explicit change to these parameters to avoid saving the fixed
|
||||
// settings in the first place.
|
||||
if changed := config.FixInvalidLocales(newConfig); changed {
|
||||
return errors.New("Invalid locale configuration")
|
||||
}
|
||||
|
||||
// make the changes persist
|
||||
app.PersistConfig()
|
||||
|
||||
// reload config
|
||||
app.ReloadConfig()
|
||||
|
||||
// Enable config watchers
|
||||
app.EnableConfigWatch()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -31,14 +31,16 @@ func TestPlugin(t *testing.T) {
|
||||
th.CheckCommand(t, "plugin", "add", filepath.Join(path, "testplugin.tar.gz"))
|
||||
|
||||
th.CheckCommand(t, "plugin", "enable", "testplugin")
|
||||
cfg, _, _, err := config.LoadConfig(th.ConfigPath())
|
||||
fs, err := config.NewFileStore(th.ConfigPath(), false)
|
||||
require.Nil(t, err)
|
||||
assert.Equal(t, cfg.PluginSettings.PluginStates["testplugin"].Enable, true)
|
||||
assert.True(t, fs.Get().PluginSettings.PluginStates["testplugin"].Enable)
|
||||
fs.Close()
|
||||
|
||||
th.CheckCommand(t, "plugin", "disable", "testplugin")
|
||||
cfg, _, _, err = config.LoadConfig(th.ConfigPath())
|
||||
fs, err = config.NewFileStore(th.ConfigPath(), false)
|
||||
require.Nil(t, err)
|
||||
assert.Equal(t, cfg.PluginSettings.PluginStates["testplugin"].Enable, false)
|
||||
assert.False(t, fs.Get().PluginSettings.PluginStates["testplugin"].Enable)
|
||||
fs.Close()
|
||||
|
||||
th.CheckCommand(t, "plugin", "list")
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# CONFIG.JSON
|
||||
# config.json
|
||||
|
||||
This is the system configuration file for your Mattermost server. Settings are specific to different editions of Mattermost. Please read the documentation before making changes: https://about.mattermost.com/default-config-docs/
|
||||
|
||||
@@ -4,393 +4,14 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/mattermost/viper"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"net/http"
|
||||
|
||||
"github.com/mattermost/mattermost-server/mlog"
|
||||
"github.com/mattermost/mattermost-server/model"
|
||||
"github.com/mattermost/mattermost-server/utils"
|
||||
"github.com/mattermost/mattermost-server/utils/fileutils"
|
||||
"github.com/mattermost/mattermost-server/utils/jsonutils"
|
||||
)
|
||||
|
||||
var (
|
||||
termsOfServiceEnabledAndEmpty = model.NewAppError(
|
||||
"Config.IsValid",
|
||||
"model.config.is_valid.support.custom_terms_of_service_text.app_error",
|
||||
nil,
|
||||
"",
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
)
|
||||
|
||||
func SaveConfig(fileName string, config *model.Config) *model.AppError {
|
||||
b, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
return model.NewAppError("SaveConfig", "utils.config.save_config.saving.app_error",
|
||||
map[string]interface{}{"Filename": fileName}, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
|
||||
err = ioutil.WriteFile(fileName, b, 0644)
|
||||
if err != nil {
|
||||
return model.NewAppError("SaveConfig", "utils.config.save_config.saving.app_error",
|
||||
map[string]interface{}{"Filename": fileName}, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type ConfigWatcher struct {
|
||||
watcher *fsnotify.Watcher
|
||||
close chan struct{}
|
||||
closed chan struct{}
|
||||
}
|
||||
|
||||
func NewConfigWatcher(cfgFileName string, f func()) (*ConfigWatcher, error) {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to create config watcher for file: "+cfgFileName)
|
||||
}
|
||||
|
||||
configFile := filepath.Clean(cfgFileName)
|
||||
configDir, _ := filepath.Split(configFile)
|
||||
watcher.Add(configDir)
|
||||
|
||||
ret := &ConfigWatcher{
|
||||
watcher: watcher,
|
||||
close: make(chan struct{}),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(ret.closed)
|
||||
defer watcher.Close()
|
||||
|
||||
for {
|
||||
select {
|
||||
case event := <-watcher.Events:
|
||||
// we only care about the config file
|
||||
if filepath.Clean(event.Name) == configFile {
|
||||
if event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create {
|
||||
mlog.Info(fmt.Sprintf("Config file watcher detected a change reloading %v", cfgFileName))
|
||||
|
||||
if _, _, configReadErr := ReadConfigFile(cfgFileName, true); configReadErr == nil {
|
||||
f()
|
||||
} else {
|
||||
mlog.Error(fmt.Sprintf("Failed to read while watching config file at %v with err=%v", cfgFileName, configReadErr.Error()))
|
||||
}
|
||||
}
|
||||
}
|
||||
case err := <-watcher.Errors:
|
||||
mlog.Error(fmt.Sprintf("Failed while watching config file at %v with err=%v", cfgFileName, err.Error()))
|
||||
case <-ret.close:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (w *ConfigWatcher) Close() {
|
||||
close(w.close)
|
||||
<-w.closed
|
||||
}
|
||||
|
||||
// ReadConfig reads and parses the given configuration.
|
||||
func ReadConfig(r io.Reader, allowEnvironmentOverrides bool) (*model.Config, map[string]interface{}, error) {
|
||||
// Pre-flight check the syntax of the configuration file to improve error messaging.
|
||||
configData, err := ioutil.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
} else {
|
||||
var rawConfig interface{}
|
||||
if err := json.Unmarshal(configData, &rawConfig); err != nil {
|
||||
return nil, nil, jsonutils.HumanizeJsonError(err, configData)
|
||||
}
|
||||
}
|
||||
|
||||
v := newViper(allowEnvironmentOverrides)
|
||||
if err := v.ReadConfig(bytes.NewReader(configData)); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var config model.Config
|
||||
unmarshalErr := v.Unmarshal(&config)
|
||||
// https://github.com/spf13/viper/issues/324
|
||||
// https://github.com/spf13/viper/issues/348
|
||||
if unmarshalErr == nil {
|
||||
config.PluginSettings.Plugins = make(map[string]map[string]interface{})
|
||||
unmarshalErr = v.UnmarshalKey("pluginsettings.plugins", &config.PluginSettings.Plugins)
|
||||
}
|
||||
if unmarshalErr == nil {
|
||||
config.PluginSettings.PluginStates = make(map[string]*model.PluginState)
|
||||
unmarshalErr = v.UnmarshalKey("pluginsettings.pluginstates", &config.PluginSettings.PluginStates)
|
||||
}
|
||||
|
||||
envConfig := v.EnvSettings()
|
||||
|
||||
var envErr error
|
||||
if envConfig, envErr = fixEnvSettingsCase(envConfig); envErr != nil {
|
||||
return nil, nil, envErr
|
||||
}
|
||||
|
||||
return &config, envConfig, unmarshalErr
|
||||
}
|
||||
|
||||
func newViper(allowEnvironmentOverrides bool) *viper.Viper {
|
||||
v := viper.New()
|
||||
|
||||
v.SetConfigType("json")
|
||||
|
||||
if allowEnvironmentOverrides {
|
||||
v.SetEnvPrefix("mm")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
v.AutomaticEnv()
|
||||
}
|
||||
|
||||
// Set zeroed defaults for all the config settings so that Viper knows what environment variables
|
||||
// it needs to be looking for. The correct defaults will later be applied using Config.SetDefaults.
|
||||
defaults := getDefaultsFromStruct(model.Config{})
|
||||
|
||||
for key, value := range defaults {
|
||||
if key == "PluginSettings.Plugins" || key == "PluginSettings.PluginStates" {
|
||||
continue
|
||||
}
|
||||
|
||||
v.SetDefault(key, value)
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func getDefaultsFromStruct(s interface{}) map[string]interface{} {
|
||||
return flattenStructToMap(structToMap(reflect.TypeOf(s)))
|
||||
}
|
||||
|
||||
// Converts a struct type into a nested map with keys matching the struct's fields and values
|
||||
// matching the zeroed value of the corresponding field.
|
||||
func structToMap(t reflect.Type) (out map[string]interface{}) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
mlog.Error(fmt.Sprintf("Panicked in structToMap. This should never happen. %v", r))
|
||||
}
|
||||
}()
|
||||
|
||||
if t.Kind() != reflect.Struct {
|
||||
// Should never hit this, but this will prevent a panic if that does happen somehow
|
||||
return nil
|
||||
}
|
||||
|
||||
out = map[string]interface{}{}
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
|
||||
var value interface{}
|
||||
|
||||
switch field.Type.Kind() {
|
||||
case reflect.Struct:
|
||||
value = structToMap(field.Type)
|
||||
case reflect.Ptr:
|
||||
indirectType := field.Type.Elem()
|
||||
|
||||
if indirectType.Kind() == reflect.Struct {
|
||||
// Follow pointers to structs since we need to define defaults for their fields
|
||||
value = structToMap(indirectType)
|
||||
} else {
|
||||
value = nil
|
||||
}
|
||||
default:
|
||||
value = reflect.Zero(field.Type).Interface()
|
||||
}
|
||||
|
||||
out[field.Name] = value
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Flattens a nested map so that the result is a single map with keys corresponding to the
|
||||
// path through the original map. For example,
|
||||
// {
|
||||
// "a": {
|
||||
// "b": 1
|
||||
// },
|
||||
// "c": "sea"
|
||||
// }
|
||||
// would flatten to
|
||||
// {
|
||||
// "a.b": 1,
|
||||
// "c": "sea"
|
||||
// }
|
||||
func flattenStructToMap(in map[string]interface{}) map[string]interface{} {
|
||||
out := make(map[string]interface{})
|
||||
|
||||
for key, value := range in {
|
||||
if valueAsMap, ok := value.(map[string]interface{}); ok {
|
||||
sub := flattenStructToMap(valueAsMap)
|
||||
|
||||
for subKey, subValue := range sub {
|
||||
out[key+"."+subKey] = subValue
|
||||
}
|
||||
} else {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// Fixes the case of the environment variables sent back from Viper since Viper stores
|
||||
// everything as lower case.
|
||||
func fixEnvSettingsCase(in map[string]interface{}) (out map[string]interface{}, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
mlog.Error(fmt.Sprintf("Panicked in fixEnvSettingsCase. This should never happen. %v", r))
|
||||
out = in
|
||||
}
|
||||
}()
|
||||
|
||||
var fixCase func(map[string]interface{}, reflect.Type) map[string]interface{}
|
||||
fixCase = func(in map[string]interface{}, t reflect.Type) map[string]interface{} {
|
||||
if t.Kind() != reflect.Struct {
|
||||
// Should never hit this, but this will prevent a panic if that does happen somehow
|
||||
return nil
|
||||
}
|
||||
|
||||
fixCaseOut := make(map[string]interface{}, len(in))
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
|
||||
key := field.Name
|
||||
if value, ok := in[strings.ToLower(key)]; ok {
|
||||
if valueAsMap, ok := value.(map[string]interface{}); ok {
|
||||
fixCaseOut[key] = fixCase(valueAsMap, field.Type)
|
||||
} else {
|
||||
fixCaseOut[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fixCaseOut
|
||||
}
|
||||
|
||||
out = fixCase(in, reflect.TypeOf(model.Config{}))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ReadConfigFile reads and parses the configuration at the given file path.
|
||||
func ReadConfigFile(path string, allowEnvironmentOverrides bool) (*model.Config, map[string]interface{}, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return ReadConfig(f, allowEnvironmentOverrides)
|
||||
}
|
||||
|
||||
// EnsureConfigFile will attempt to locate a config file with the given name. If it does not exist,
|
||||
// it will attempt to locate a default config file, and copy it to a file named fileName in the same
|
||||
// directory. In either case, the config file path is returned.
|
||||
func EnsureConfigFile(fileName string) (string, error) {
|
||||
if configFile := fileutils.FindConfigFile(fileName); configFile != "" {
|
||||
return configFile, nil
|
||||
}
|
||||
if defaultPath := fileutils.FindConfigFile("default.json"); defaultPath != "" {
|
||||
destPath := filepath.Join(filepath.Dir(defaultPath), fileName)
|
||||
src, err := os.Open(defaultPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer src.Close()
|
||||
dest, err := os.OpenFile(destPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer dest.Close()
|
||||
if _, err := io.Copy(dest, src); err == nil {
|
||||
return destPath, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no config file found")
|
||||
}
|
||||
|
||||
// LoadConfig will try to search around for the corresponding config file. It will search
|
||||
// /tmp/fileName then attempt ./config/fileName, then ../config/fileName and last it will look at
|
||||
// fileName.
|
||||
func LoadConfig(fileName string) (*model.Config, string, map[string]interface{}, *model.AppError) {
|
||||
var configPath string
|
||||
|
||||
if fileName != filepath.Base(fileName) {
|
||||
configPath = fileName
|
||||
} else {
|
||||
if path, err := EnsureConfigFile(fileName); err != nil {
|
||||
appErr := model.NewAppError("LoadConfig", "utils.config.load_config.opening.panic", map[string]interface{}{"Filename": fileName, "Error": err.Error()}, "", 0)
|
||||
return nil, "", nil, appErr
|
||||
} else {
|
||||
configPath = path
|
||||
}
|
||||
}
|
||||
|
||||
config, envConfig, err := ReadConfigFile(configPath, true)
|
||||
if err != nil {
|
||||
appErr := model.NewAppError("LoadConfig", "utils.config.load_config.decoding.panic", map[string]interface{}{"Filename": fileName, "Error": err.Error()}, "", 0)
|
||||
return nil, "", nil, appErr
|
||||
}
|
||||
|
||||
needSave := config.SqlSettings.AtRestEncryptKey == nil || len(*config.SqlSettings.AtRestEncryptKey) == 0 ||
|
||||
config.FileSettings.PublicLinkSalt == nil || len(*config.FileSettings.PublicLinkSalt) == 0 ||
|
||||
config.EmailSettings.InviteSalt == nil || len(*config.EmailSettings.InviteSalt) == 0
|
||||
|
||||
config.SetDefaults()
|
||||
|
||||
// Don't treat it as an error right now if custom terms of service are enabled but text is empty.
|
||||
// This is because terms of service text will be fetched from database at a later state, but
|
||||
// the flag indicating it is enabled is fetched from config file right away.
|
||||
if err := config.IsValid(); err != nil && err.Id != termsOfServiceEnabledAndEmpty.Id {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
|
||||
if needSave {
|
||||
if err := SaveConfig(configPath, config); err != nil {
|
||||
mlog.Warn(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if err := ValidateLocales(config); err != nil {
|
||||
if err := SaveConfig(configPath, config); err != nil {
|
||||
mlog.Warn(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if *config.FileSettings.DriverName == model.IMAGE_DRIVER_LOCAL {
|
||||
dir := config.FileSettings.Directory
|
||||
dirString := *dir
|
||||
if len(*dir) > 0 && dirString[len(dirString)-1:] != "/" {
|
||||
*config.FileSettings.Directory += "/"
|
||||
}
|
||||
}
|
||||
|
||||
return config, configPath, envConfig, nil
|
||||
}
|
||||
|
||||
// GenerateClientConfig renders the given configuration for a client.
|
||||
func GenerateClientConfig(c *model.Config, diagnosticId string, license *model.License) map[string]string {
|
||||
props := GenerateLimitedClientConfig(c, diagnosticId, license)
|
||||
|
||||
@@ -564,6 +185,7 @@ func GenerateClientConfig(c *model.Config, diagnosticId string, license *model.L
|
||||
return props
|
||||
}
|
||||
|
||||
// GenerateLimitedClientConfig renders the given configuration for an untrusted client.
|
||||
func GenerateLimitedClientConfig(c *model.Config, diagnosticId string, license *model.License) map[string]string {
|
||||
props := make(map[string]string)
|
||||
|
||||
@@ -680,44 +302,3 @@ func GenerateLimitedClientConfig(c *model.Config, diagnosticId string, license *
|
||||
|
||||
return props
|
||||
}
|
||||
|
||||
func ValidateLocales(cfg *model.Config) *model.AppError {
|
||||
var err *model.AppError
|
||||
locales := utils.GetSupportedLocales()
|
||||
if _, ok := locales[*cfg.LocalizationSettings.DefaultServerLocale]; !ok {
|
||||
*cfg.LocalizationSettings.DefaultServerLocale = model.DEFAULT_LOCALE
|
||||
err = model.NewAppError("ValidateLocales", "utils.config.supported_server_locale.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if _, ok := locales[*cfg.LocalizationSettings.DefaultClientLocale]; !ok {
|
||||
*cfg.LocalizationSettings.DefaultClientLocale = model.DEFAULT_LOCALE
|
||||
err = model.NewAppError("ValidateLocales", "utils.config.supported_client_locale.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if len(*cfg.LocalizationSettings.AvailableLocales) > 0 {
|
||||
isDefaultClientLocaleInAvailableLocales := false
|
||||
for _, word := range strings.Split(*cfg.LocalizationSettings.AvailableLocales, ",") {
|
||||
if _, ok := locales[word]; !ok {
|
||||
*cfg.LocalizationSettings.AvailableLocales = ""
|
||||
isDefaultClientLocaleInAvailableLocales = true
|
||||
err = model.NewAppError("ValidateLocales", "utils.config.supported_available_locales.app_error", nil, "", http.StatusBadRequest)
|
||||
break
|
||||
}
|
||||
|
||||
if word == *cfg.LocalizationSettings.DefaultClientLocale {
|
||||
isDefaultClientLocaleInAvailableLocales = true
|
||||
}
|
||||
}
|
||||
|
||||
availableLocales := *cfg.LocalizationSettings.AvailableLocales
|
||||
|
||||
if !isDefaultClientLocaleInAvailableLocales {
|
||||
availableLocales += "," + *cfg.LocalizationSettings.DefaultClientLocale
|
||||
err = model.NewAppError("ValidateLocales", "utils.config.add_client_locale.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
*cfg.LocalizationSettings.AvailableLocales = strings.Join(utils.RemoveDuplicatesFromStringArray(strings.Split(availableLocales, ",")), ",")
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
233
config/client_test.go
Normal file
233
config/client_test.go
Normal file
@@ -0,0 +1,233 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mattermost/mattermost-server/config"
|
||||
"github.com/mattermost/mattermost-server/model"
|
||||
)
|
||||
|
||||
func TestGetClientConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := []struct {
|
||||
description string
|
||||
config *model.Config
|
||||
diagnosticId string
|
||||
license *model.License
|
||||
expectedFields map[string]string
|
||||
}{
|
||||
{
|
||||
"unlicensed",
|
||||
&model.Config{
|
||||
EmailSettings: model.EmailSettings{
|
||||
EmailNotificationContentsType: sToP(model.EMAIL_NOTIFICATION_CONTENTS_FULL),
|
||||
},
|
||||
ThemeSettings: model.ThemeSettings{
|
||||
// Ignored, since not licensed.
|
||||
AllowCustomThemes: bToP(false),
|
||||
},
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
WebsocketURL: sToP("ws://mattermost.example.com:8065"),
|
||||
WebsocketPort: iToP(80),
|
||||
WebsocketSecurePort: iToP(443),
|
||||
},
|
||||
},
|
||||
"",
|
||||
nil,
|
||||
map[string]string{
|
||||
"DiagnosticId": "",
|
||||
"EmailNotificationContentsType": "full",
|
||||
"AllowCustomThemes": "true",
|
||||
"EnforceMultifactorAuthentication": "false",
|
||||
"WebsocketURL": "ws://mattermost.example.com:8065",
|
||||
"WebsocketPort": "80",
|
||||
"WebsocketSecurePort": "443",
|
||||
},
|
||||
},
|
||||
{
|
||||
"licensed, but not for theme management",
|
||||
&model.Config{
|
||||
EmailSettings: model.EmailSettings{
|
||||
EmailNotificationContentsType: sToP(model.EMAIL_NOTIFICATION_CONTENTS_FULL),
|
||||
},
|
||||
ThemeSettings: model.ThemeSettings{
|
||||
// Ignored, since not licensed.
|
||||
AllowCustomThemes: bToP(false),
|
||||
},
|
||||
},
|
||||
"tag1",
|
||||
&model.License{
|
||||
Features: &model.Features{
|
||||
ThemeManagement: bToP(false),
|
||||
},
|
||||
},
|
||||
map[string]string{
|
||||
"DiagnosticId": "tag1",
|
||||
"EmailNotificationContentsType": "full",
|
||||
"AllowCustomThemes": "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
"licensed for theme management",
|
||||
&model.Config{
|
||||
EmailSettings: model.EmailSettings{
|
||||
EmailNotificationContentsType: sToP(model.EMAIL_NOTIFICATION_CONTENTS_FULL),
|
||||
},
|
||||
ThemeSettings: model.ThemeSettings{
|
||||
AllowCustomThemes: bToP(false),
|
||||
},
|
||||
},
|
||||
"tag2",
|
||||
&model.License{
|
||||
Features: &model.Features{
|
||||
ThemeManagement: bToP(true),
|
||||
},
|
||||
},
|
||||
map[string]string{
|
||||
"DiagnosticId": "tag2",
|
||||
"EmailNotificationContentsType": "full",
|
||||
"AllowCustomThemes": "false",
|
||||
},
|
||||
},
|
||||
{
|
||||
"licensed for enforcement",
|
||||
&model.Config{
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
EnforceMultifactorAuthentication: bToP(true),
|
||||
},
|
||||
},
|
||||
"tag1",
|
||||
&model.License{
|
||||
Features: &model.Features{
|
||||
MFA: bToP(true),
|
||||
},
|
||||
},
|
||||
map[string]string{
|
||||
"EnforceMultifactorAuthentication": "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
"experimental channel organization enabled",
|
||||
&model.Config{
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
ExperimentalChannelOrganization: bToP(true),
|
||||
},
|
||||
},
|
||||
"tag1",
|
||||
nil,
|
||||
map[string]string{
|
||||
"ExperimentalChannelOrganization": "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
"experimental channel organization disabled, but experimental group unread channels on",
|
||||
&model.Config{
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
ExperimentalChannelOrganization: bToP(false),
|
||||
ExperimentalGroupUnreadChannels: sToP(model.GROUP_UNREAD_CHANNELS_DEFAULT_ON),
|
||||
},
|
||||
},
|
||||
"tag1",
|
||||
nil,
|
||||
map[string]string{
|
||||
"ExperimentalChannelOrganization": "true",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
testCase := testCase
|
||||
t.Run(testCase.description, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCase.config.SetDefaults()
|
||||
if testCase.license != nil {
|
||||
testCase.license.Features.SetDefaults()
|
||||
}
|
||||
|
||||
configMap := config.GenerateClientConfig(testCase.config, testCase.diagnosticId, testCase.license)
|
||||
for expectedField, expectedValue := range testCase.expectedFields {
|
||||
actualValue, ok := configMap[expectedField]
|
||||
if assert.True(t, ok, fmt.Sprintf("config does not contain %v", expectedField)) {
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLimitedClientConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := []struct {
|
||||
description string
|
||||
config *model.Config
|
||||
diagnosticId string
|
||||
license *model.License
|
||||
expectedFields map[string]string
|
||||
}{
|
||||
{
|
||||
"unlicensed",
|
||||
&model.Config{
|
||||
EmailSettings: model.EmailSettings{
|
||||
EmailNotificationContentsType: sToP(model.EMAIL_NOTIFICATION_CONTENTS_FULL),
|
||||
},
|
||||
ThemeSettings: model.ThemeSettings{
|
||||
// Ignored, since not licensed.
|
||||
AllowCustomThemes: bToP(false),
|
||||
},
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
WebsocketURL: sToP("ws://mattermost.example.com:8065"),
|
||||
WebsocketPort: iToP(80),
|
||||
WebsocketSecurePort: iToP(443),
|
||||
},
|
||||
},
|
||||
"",
|
||||
nil,
|
||||
map[string]string{
|
||||
"DiagnosticId": "",
|
||||
"EnforceMultifactorAuthentication": "false",
|
||||
"WebsocketURL": "ws://mattermost.example.com:8065",
|
||||
"WebsocketPort": "80",
|
||||
"WebsocketSecurePort": "443",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
testCase := testCase
|
||||
t.Run(testCase.description, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCase.config.SetDefaults()
|
||||
if testCase.license != nil {
|
||||
testCase.license.Features.SetDefaults()
|
||||
}
|
||||
|
||||
configMap := config.GenerateLimitedClientConfig(testCase.config, testCase.diagnosticId, testCase.license)
|
||||
for expectedField, expectedValue := range testCase.expectedFields {
|
||||
actualValue, ok := configMap[expectedField]
|
||||
if assert.True(t, ok, fmt.Sprintf("config does not contain %v", expectedField)) {
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func sToP(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func bToP(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
func iToP(i int) *int {
|
||||
return &i
|
||||
}
|
||||
39
config/emitter.go
Normal file
39
config/emitter.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/mattermost/mattermost-server/model"
|
||||
)
|
||||
|
||||
// emitter enables threadsafe registration and broadcasting to configuration listeners
|
||||
type emitter struct {
|
||||
listeners sync.Map
|
||||
}
|
||||
|
||||
// AddListener adds a callback function to invoke when the configuration is modified.
|
||||
func (e *emitter) AddListener(listener Listener) string {
|
||||
id := model.NewId()
|
||||
|
||||
e.listeners.Store(id, listener)
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
// RemoveListener removes a callback function using an id returned from AddListener.
|
||||
func (e *emitter) RemoveListener(id string) {
|
||||
e.listeners.Delete(id)
|
||||
}
|
||||
|
||||
// invokeConfigListeners synchronously notifies all listeners about the configuration change.
|
||||
func (e *emitter) invokeConfigListeners(oldCfg, newCfg *model.Config) {
|
||||
e.listeners.Range(func(key, value interface{}) bool {
|
||||
listener := value.(Listener)
|
||||
listener(oldCfg, newCfg)
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
53
config/emitter_test.go
Normal file
53
config/emitter_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mattermost/mattermost-server/model"
|
||||
)
|
||||
|
||||
func TestEmitter(t *testing.T) {
|
||||
var e emitter
|
||||
|
||||
expectedOldCfg := &model.Config{}
|
||||
expectedNewCfg := &model.Config{}
|
||||
|
||||
listener1 := false
|
||||
id1 := e.AddListener(func(oldCfg, newCfg *model.Config) {
|
||||
assert.Equal(t, expectedOldCfg, oldCfg)
|
||||
assert.Equal(t, expectedNewCfg, newCfg)
|
||||
listener1 = true
|
||||
})
|
||||
|
||||
listener2 := false
|
||||
id2 := e.AddListener(func(oldCfg, newCfg *model.Config) {
|
||||
assert.Equal(t, expectedOldCfg, oldCfg)
|
||||
assert.Equal(t, expectedNewCfg, newCfg)
|
||||
listener2 = true
|
||||
})
|
||||
|
||||
e.invokeConfigListeners(expectedOldCfg, expectedNewCfg)
|
||||
assert.True(t, listener1, "listener 1 not called")
|
||||
assert.True(t, listener2, "listener 2 not called")
|
||||
|
||||
e.RemoveListener(id2)
|
||||
|
||||
listener1 = false
|
||||
listener2 = false
|
||||
e.invokeConfigListeners(expectedOldCfg, expectedNewCfg)
|
||||
assert.True(t, listener1, "listener 1 not called")
|
||||
assert.False(t, listener2, "listener 2 should not have been called")
|
||||
|
||||
e.RemoveListener(id1)
|
||||
|
||||
listener1 = false
|
||||
listener2 = false
|
||||
e.invokeConfigListeners(expectedOldCfg, expectedNewCfg)
|
||||
assert.False(t, listener1, "listener 1 should not have been called")
|
||||
assert.False(t, listener2, "listener 2 should not have been called")
|
||||
}
|
||||
308
config/file.go
Normal file
308
config/file.go
Normal file
@@ -0,0 +1,308 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/mattermost/mattermost-server/mlog"
|
||||
"github.com/mattermost/mattermost-server/model"
|
||||
"github.com/mattermost/mattermost-server/utils/fileutils"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrReadOnlyConfiguration = errors.New("configuration is read-only")
|
||||
)
|
||||
|
||||
// FileStore is a config store backed by a file such as config/config.json.
|
||||
type FileStore struct {
|
||||
emitter
|
||||
|
||||
config *model.Config
|
||||
environmentOverrides map[string]interface{}
|
||||
configLock sync.RWMutex
|
||||
path string
|
||||
watch bool
|
||||
watcher *watcher
|
||||
}
|
||||
|
||||
// NewFileStore creates a new instance of a config store backed by the given file path.
|
||||
//
|
||||
// If watch is true, any external changes to the file will force a reload.
|
||||
func NewFileStore(path string, watch bool) (fs *FileStore, err error) {
|
||||
resolvedPath, err := resolveConfigFilePath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fs = &FileStore{
|
||||
path: resolvedPath,
|
||||
watch: watch,
|
||||
}
|
||||
if err = fs.Load(); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to load")
|
||||
}
|
||||
|
||||
if fs.watch {
|
||||
if err = fs.startWatcher(); err != nil {
|
||||
mlog.Error("failed to start config watcher", mlog.String("path", path), mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
return fs, nil
|
||||
}
|
||||
|
||||
// resolveConfigFilePath attempts to resolve the given configuration file path to an absolute path.
|
||||
//
|
||||
// Consideration is given to maintaining backwards compatibility when resolving the path to the
|
||||
// configuration file.
|
||||
func resolveConfigFilePath(path string) (string, error) {
|
||||
// Absolute paths are explicit and require no resolution.
|
||||
if filepath.IsAbs(path) {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// Search for the given relative path (or plain filename) in various directories,
|
||||
// resolving to the corresponding absolute path if found. FindConfigFile takes into account
|
||||
// various common search paths rooted both at the current working directory and relative
|
||||
// to the executable.
|
||||
if configFile := fileutils.FindConfigFile(path); configFile != "" {
|
||||
return configFile, nil
|
||||
}
|
||||
|
||||
// Otherwise, search for the config/ folder using the same heuristics as above, and build
|
||||
// an absolute path anchored there and joining the given input path (or plain filename).
|
||||
if configFolder, found := fileutils.FindDir("config"); found {
|
||||
return filepath.Join(configFolder, path), nil
|
||||
}
|
||||
|
||||
// Fail altogether if we can't even find the config/ folder. This should only happen if
|
||||
// the executable is relocated away from the supporting files.
|
||||
return "", fmt.Errorf("failed to find config file %s", path)
|
||||
}
|
||||
|
||||
// Get fetches the current, cached configuration.
|
||||
func (fs *FileStore) Get() *model.Config {
|
||||
fs.configLock.RLock()
|
||||
defer fs.configLock.RUnlock()
|
||||
|
||||
return fs.config
|
||||
}
|
||||
|
||||
// GetEnvironmentOverrides fetches the configuration fields overridden by environment variables.
|
||||
func (fs *FileStore) GetEnvironmentOverrides() map[string]interface{} {
|
||||
fs.configLock.RLock()
|
||||
defer fs.configLock.RUnlock()
|
||||
|
||||
return fs.environmentOverrides
|
||||
}
|
||||
|
||||
// Set replaces the current configuration in its entirety, without updating the backing store.
|
||||
func (fs *FileStore) Set(newCfg *model.Config) (*model.Config, error) {
|
||||
fs.configLock.Lock()
|
||||
var unlockOnce sync.Once
|
||||
defer unlockOnce.Do(fs.configLock.Unlock)
|
||||
|
||||
oldCfg := fs.config
|
||||
|
||||
// TODO: disallow attempting to save a directly modified config (comparing pointers). This
|
||||
// wouldn't be an exhaustive check, given the use of pointers throughout the data
|
||||
// structure, but might prevent common mistakes. Requires upstream changes first.
|
||||
// if newCfg == oldCfg {
|
||||
// return nil, errors.New("old configuration modified instead of cloning")
|
||||
// }
|
||||
|
||||
newCfg = newCfg.Clone()
|
||||
newCfg.SetDefaults()
|
||||
|
||||
// Sometimes the config is received with "fake" data in sensitive fields. Apply the real
|
||||
// data from the existing config as necessary.
|
||||
desanitize(oldCfg, newCfg)
|
||||
|
||||
if err := newCfg.IsValid(); err != nil {
|
||||
return nil, errors.Wrap(err, "new configuration is invalid")
|
||||
}
|
||||
|
||||
if *oldCfg.ClusterSettings.Enable && *oldCfg.ClusterSettings.ReadOnlyConfig {
|
||||
return nil, ErrReadOnlyConfiguration
|
||||
}
|
||||
|
||||
// Ideally, Set would persist automatically and abstract this completely away from the
|
||||
// client. Doing so requires a few upstream changes first, so for now an explicit Save()
|
||||
// remains required.
|
||||
// if err := fs.persist(newCfg); err != nil {
|
||||
// return nil, errors.Wrap(err, "failed to persist")
|
||||
// }
|
||||
|
||||
fs.config = newCfg
|
||||
|
||||
unlockOnce.Do(fs.configLock.Unlock)
|
||||
|
||||
// Notify listeners synchronously. Ideally, this would be asynchronous, but existing code
|
||||
// assumes this and there would be increased complexity to avoid racing updates.
|
||||
fs.invokeConfigListeners(oldCfg, newCfg)
|
||||
|
||||
return oldCfg, nil
|
||||
}
|
||||
|
||||
// persist writes the configuration to the configured file.
|
||||
func (fs *FileStore) persist(cfg *model.Config) error {
|
||||
fs.stopWatcher()
|
||||
|
||||
b, err := marshalConfig(cfg)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to serialize")
|
||||
}
|
||||
|
||||
err = ioutil.WriteFile(fs.path, b, 0644)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to write file")
|
||||
}
|
||||
|
||||
if fs.watch {
|
||||
if err = fs.startWatcher(); err != nil {
|
||||
mlog.Error("failed to start config watcher", mlog.String("path", fs.path), mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load updates the current configuration from the backing store.
|
||||
func (fs *FileStore) Load() (err error) {
|
||||
var needsSave bool
|
||||
var f io.ReadCloser
|
||||
|
||||
f, err = os.Open(fs.path)
|
||||
if os.IsNotExist(err) {
|
||||
needsSave = true
|
||||
defaultCfg := model.Config{}
|
||||
defaultCfg.SetDefaults()
|
||||
|
||||
var defaultCfgBytes []byte
|
||||
defaultCfgBytes, err = marshalConfig(&defaultCfg)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to serialize default config")
|
||||
}
|
||||
|
||||
f = ioutil.NopCloser(bytes.NewReader(defaultCfgBytes))
|
||||
|
||||
} else if err != nil {
|
||||
return errors.Wrapf(err, "failed to open %s for reading", fs.path)
|
||||
}
|
||||
defer func() {
|
||||
closeErr := f.Close()
|
||||
if err == nil && closeErr != nil {
|
||||
err = errors.Wrap(closeErr, "failed to close")
|
||||
}
|
||||
}()
|
||||
|
||||
allowEnvironmentOverrides := true
|
||||
loadedCfg, environmentOverrides, err := unmarshalConfig(f, allowEnvironmentOverrides)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to unmarshal config from %s", fs.path)
|
||||
}
|
||||
|
||||
// SetDefaults generates various keys and salts if not previously configured. Determine if
|
||||
// such a change will be made before invoking. This method will not effect the save: that
|
||||
// remains the responsibility of the caller.
|
||||
needsSave = needsSave || loadedCfg.SqlSettings.AtRestEncryptKey == nil || len(*loadedCfg.SqlSettings.AtRestEncryptKey) == 0
|
||||
needsSave = needsSave || loadedCfg.FileSettings.PublicLinkSalt == nil || len(*loadedCfg.FileSettings.PublicLinkSalt) == 0
|
||||
needsSave = needsSave || loadedCfg.EmailSettings.InviteSalt == nil || len(*loadedCfg.EmailSettings.InviteSalt) == 0
|
||||
|
||||
loadedCfg.SetDefaults()
|
||||
|
||||
if err := loadedCfg.IsValid(); err != nil {
|
||||
return errors.Wrap(err, "invalid config")
|
||||
}
|
||||
|
||||
if changed := fixConfig(loadedCfg); changed {
|
||||
needsSave = true
|
||||
}
|
||||
|
||||
fs.configLock.Lock()
|
||||
var unlockOnce sync.Once
|
||||
defer unlockOnce.Do(fs.configLock.Unlock)
|
||||
|
||||
if needsSave {
|
||||
if err = fs.persist(loadedCfg); err != nil {
|
||||
return errors.Wrap(err, "failed to persist required changes after load")
|
||||
}
|
||||
}
|
||||
|
||||
oldCfg := fs.config
|
||||
fs.config = loadedCfg
|
||||
fs.environmentOverrides = environmentOverrides
|
||||
|
||||
unlockOnce.Do(fs.configLock.Unlock)
|
||||
|
||||
// Notify listeners synchronously. Ideally, this would be asynchronous, but existing code
|
||||
// assumes this and there would be increased complexity to avoid racing updates.
|
||||
fs.invokeConfigListeners(oldCfg, loadedCfg)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save writes the current configuration to the backing store.
|
||||
func (fs *FileStore) Save() error {
|
||||
fs.configLock.Lock()
|
||||
defer fs.configLock.Unlock()
|
||||
|
||||
return fs.persist(fs.config)
|
||||
}
|
||||
|
||||
// startWatcher starts a watcher to monitor for external config file changes.
|
||||
func (fs *FileStore) startWatcher() error {
|
||||
if fs.watcher != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
watcher, err := newWatcher(fs.path, func() {
|
||||
if err := fs.Load(); err != nil {
|
||||
mlog.Error("failed to reload file on change", mlog.String("path", fs.path), mlog.Err(err))
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fs.watcher = watcher
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// stopWatcher stops any previously started watcher.
|
||||
func (fs *FileStore) stopWatcher() {
|
||||
if fs.watcher == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := fs.watcher.Close(); err != nil {
|
||||
mlog.Error("failed to close watcher", mlog.Err(err))
|
||||
}
|
||||
fs.watcher = nil
|
||||
}
|
||||
|
||||
// String returns the path to the file backing the config.
|
||||
func (fs *FileStore) String() string {
|
||||
return "file://" + fs.path
|
||||
}
|
||||
|
||||
// Close cleans up resources associated with the store.
|
||||
func (fs *FileStore) Close() error {
|
||||
fs.configLock.Lock()
|
||||
defer fs.configLock.Unlock()
|
||||
|
||||
fs.stopWatcher()
|
||||
|
||||
return nil
|
||||
}
|
||||
655
config/file_test.go
Normal file
655
config/file_test.go
Normal file
@@ -0,0 +1,655 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/config"
|
||||
"github.com/mattermost/mattermost-server/model"
|
||||
"github.com/mattermost/mattermost-server/utils"
|
||||
)
|
||||
|
||||
var emptyConfig, readOnlyConfig, minimalConfig, invalidConfig, fixesRequiredConfig, ldapConfig, testConfig *model.Config
|
||||
|
||||
func init() {
|
||||
emptyConfig = &model.Config{}
|
||||
readOnlyConfig = &model.Config{
|
||||
ClusterSettings: model.ClusterSettings{
|
||||
Enable: bToP(true),
|
||||
ReadOnlyConfig: bToP(true),
|
||||
},
|
||||
}
|
||||
minimalConfig = &model.Config{
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
SiteURL: sToP("http://minimal"),
|
||||
},
|
||||
SqlSettings: model.SqlSettings{
|
||||
AtRestEncryptKey: sToP("abcdefghijklmnopqrstuvwxyz0123456789"),
|
||||
},
|
||||
FileSettings: model.FileSettings{
|
||||
PublicLinkSalt: sToP("abcdefghijklmnopqrstuvwxyz0123456789"),
|
||||
},
|
||||
EmailSettings: model.EmailSettings{
|
||||
InviteSalt: sToP("abcdefghijklmnopqrstuvwxyz0123456789"),
|
||||
},
|
||||
LocalizationSettings: model.LocalizationSettings{
|
||||
DefaultServerLocale: sToP("en"),
|
||||
DefaultClientLocale: sToP("en"),
|
||||
},
|
||||
}
|
||||
invalidConfig = &model.Config{
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
SiteURL: sToP("invalid"),
|
||||
},
|
||||
}
|
||||
fixesRequiredConfig = &model.Config{
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
SiteURL: sToP("http://trailingslash/"),
|
||||
},
|
||||
SqlSettings: model.SqlSettings{
|
||||
AtRestEncryptKey: sToP("abcdefghijklmnopqrstuvwxyz0123456789"),
|
||||
},
|
||||
FileSettings: model.FileSettings{
|
||||
DriverName: sToP(model.IMAGE_DRIVER_LOCAL),
|
||||
Directory: sToP("/path/to/directory"),
|
||||
PublicLinkSalt: sToP("abcdefghijklmnopqrstuvwxyz0123456789"),
|
||||
},
|
||||
EmailSettings: model.EmailSettings{
|
||||
InviteSalt: sToP("abcdefghijklmnopqrstuvwxyz0123456789"),
|
||||
},
|
||||
LocalizationSettings: model.LocalizationSettings{
|
||||
DefaultServerLocale: sToP("garbage"),
|
||||
DefaultClientLocale: sToP("garbage"),
|
||||
},
|
||||
}
|
||||
ldapConfig = &model.Config{
|
||||
LdapSettings: model.LdapSettings{
|
||||
BindPassword: sToP("password"),
|
||||
},
|
||||
}
|
||||
testConfig = &model.Config{
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
SiteURL: sToP("http://TestStoreNew"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func setupConfigFile(t *testing.T, cfg *model.Config) (string, func()) {
|
||||
os.Clearenv()
|
||||
t.Helper()
|
||||
|
||||
tempDir, err := ioutil.TempDir("", "setupConfigFile")
|
||||
require.NoError(t, err)
|
||||
|
||||
f, err := ioutil.TempFile(tempDir, "setupConfigFile")
|
||||
require.NoError(t, err)
|
||||
|
||||
cfgData, err := config.MarshalConfig(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
ioutil.WriteFile(f.Name(), cfgData, 0644)
|
||||
|
||||
return f.Name(), func() {
|
||||
os.RemoveAll(tempDir)
|
||||
}
|
||||
}
|
||||
|
||||
// assertFileEqualsConfig verifies the on disk contents of the given path equal the given config.
|
||||
func assertFileEqualsConfig(t *testing.T, expectedCfg *model.Config, path string) {
|
||||
f, err := os.Open(path)
|
||||
require.Nil(t, err)
|
||||
|
||||
// These fields require special initialization for our tests.
|
||||
expectedCfg = expectedCfg.Clone()
|
||||
expectedCfg.MessageExportSettings.GlobalRelaySettings = &model.GlobalRelayMessageExportSettings{}
|
||||
expectedCfg.PluginSettings.Plugins = make(map[string]map[string]interface{})
|
||||
expectedCfg.PluginSettings.PluginStates = make(map[string]*model.PluginState)
|
||||
|
||||
actualCfg, _, err := config.UnmarshalConfig(f, false)
|
||||
require.Nil(t, err)
|
||||
|
||||
assert.Equal(t, expectedCfg, actualCfg)
|
||||
}
|
||||
|
||||
// assertFileNotEqualsConfig verifies the on disk contents of the given path does not equal the given config.
|
||||
func assertFileNotEqualsConfig(t *testing.T, expectedCfg *model.Config, path string) {
|
||||
f, err := os.Open(path)
|
||||
require.Nil(t, err)
|
||||
|
||||
// These fields require special initialization for our tests.
|
||||
expectedCfg = expectedCfg.Clone()
|
||||
expectedCfg.MessageExportSettings.GlobalRelaySettings = &model.GlobalRelayMessageExportSettings{}
|
||||
expectedCfg.PluginSettings.Plugins = make(map[string]map[string]interface{})
|
||||
expectedCfg.PluginSettings.PluginStates = make(map[string]*model.PluginState)
|
||||
|
||||
actualCfg, _, err := config.UnmarshalConfig(f, false)
|
||||
require.Nil(t, err)
|
||||
|
||||
assert.NotEqual(t, expectedCfg, actualCfg)
|
||||
}
|
||||
|
||||
func TestFileStoreNew(t *testing.T) {
|
||||
utils.TranslationsPreInit()
|
||||
|
||||
t.Run("absolute path, initialization required", func(t *testing.T) {
|
||||
path, tearDown := setupConfigFile(t, testConfig)
|
||||
defer tearDown()
|
||||
|
||||
fs, err := config.NewFileStore(path, false)
|
||||
require.NoError(t, err)
|
||||
defer fs.Close()
|
||||
|
||||
assert.Equal(t, "http://TestStoreNew", *fs.Get().ServiceSettings.SiteURL)
|
||||
assertFileNotEqualsConfig(t, testConfig, path)
|
||||
})
|
||||
|
||||
t.Run("absolute path, already minimally configured", func(t *testing.T) {
|
||||
path, tearDown := setupConfigFile(t, minimalConfig)
|
||||
defer tearDown()
|
||||
|
||||
fs, err := config.NewFileStore(path, false)
|
||||
require.NoError(t, err)
|
||||
defer fs.Close()
|
||||
|
||||
assert.Equal(t, "http://minimal", *fs.Get().ServiceSettings.SiteURL)
|
||||
assertFileEqualsConfig(t, minimalConfig, path)
|
||||
})
|
||||
|
||||
t.Run("absolute path, file does not exist", func(t *testing.T) {
|
||||
tempDir, err := ioutil.TempDir("", "TestFileStoreNew")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
path := filepath.Join(tempDir, "does_not_exist")
|
||||
fs, err := config.NewFileStore(path, false)
|
||||
require.NoError(t, err)
|
||||
defer fs.Close()
|
||||
|
||||
assert.Equal(t, model.SERVICE_SETTINGS_DEFAULT_SITE_URL, *fs.Get().ServiceSettings.SiteURL)
|
||||
assertFileNotEqualsConfig(t, testConfig, path)
|
||||
})
|
||||
|
||||
t.Run("absolute path, path to file does not exist", func(t *testing.T) {
|
||||
tempDir, err := ioutil.TempDir("", "TestFileStoreNew")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
path := filepath.Join(tempDir, "does/not/exist")
|
||||
_, err = config.NewFileStore(path, false)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("relative path, file exists", func(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
err := os.MkdirAll("TestFileStoreNew/a/b/c", 0700)
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll("TestFileStoreNew")
|
||||
|
||||
path := "TestFileStoreNew/a/b/c/config.json"
|
||||
|
||||
cfgData, err := config.MarshalConfig(testConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
ioutil.WriteFile(path, cfgData, 0644)
|
||||
|
||||
fs, err := config.NewFileStore(path, false)
|
||||
require.NoError(t, err)
|
||||
defer fs.Close()
|
||||
|
||||
assert.Equal(t, "http://TestStoreNew", *fs.Get().ServiceSettings.SiteURL)
|
||||
assertFileNotEqualsConfig(t, testConfig, path)
|
||||
})
|
||||
|
||||
t.Run("relative path, file does not exist", func(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
err := os.MkdirAll("TestFileStoreNew/a/b/c", 0700)
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll("TestFileStoreNew")
|
||||
|
||||
path := "TestFileStoreNew/a/b/c/config.json"
|
||||
fs, err := config.NewFileStore(path, false)
|
||||
require.NoError(t, err)
|
||||
defer fs.Close()
|
||||
|
||||
assert.Equal(t, model.SERVICE_SETTINGS_DEFAULT_SITE_URL, *fs.Get().ServiceSettings.SiteURL)
|
||||
assertFileNotEqualsConfig(t, testConfig, path)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFileStoreGet(t *testing.T) {
|
||||
path, tearDown := setupConfigFile(t, testConfig)
|
||||
defer tearDown()
|
||||
|
||||
fs, err := config.NewFileStore(path, false)
|
||||
require.NoError(t, err)
|
||||
defer fs.Close()
|
||||
|
||||
cfg := fs.Get()
|
||||
assert.Equal(t, "http://TestStoreNew", *cfg.ServiceSettings.SiteURL)
|
||||
|
||||
cfg2 := fs.Get()
|
||||
assert.Equal(t, "http://TestStoreNew", *cfg.ServiceSettings.SiteURL)
|
||||
|
||||
assert.True(t, cfg == cfg2, "Get() returned different configuration instances")
|
||||
|
||||
newCfg := &model.Config{}
|
||||
oldCfg, err := fs.Set(newCfg)
|
||||
|
||||
assert.True(t, oldCfg == cfg, "returned config after set() changed original")
|
||||
assert.False(t, newCfg == cfg, "returned config should have been different from original")
|
||||
}
|
||||
|
||||
func TestFileStoreGetEnivironmentOverrides(t *testing.T) {
|
||||
path, tearDown := setupConfigFile(t, testConfig)
|
||||
defer tearDown()
|
||||
|
||||
fs, err := config.NewFileStore(path, false)
|
||||
require.NoError(t, err)
|
||||
defer fs.Close()
|
||||
|
||||
assert.Equal(t, "http://TestStoreNew", *fs.Get().ServiceSettings.SiteURL)
|
||||
assert.Empty(t, fs.GetEnvironmentOverrides())
|
||||
|
||||
os.Setenv("MM_SERVICESETTINGS_SITEURL", "http://override")
|
||||
|
||||
fs, err = config.NewFileStore(path, false)
|
||||
require.NoError(t, err)
|
||||
defer fs.Close()
|
||||
|
||||
assert.Equal(t, "http://override", *fs.Get().ServiceSettings.SiteURL)
|
||||
assert.Equal(t, map[string]interface{}{"ServiceSettings": map[string]interface{}{"SiteURL": true}}, fs.GetEnvironmentOverrides())
|
||||
}
|
||||
|
||||
func TestFileStoreSet(t *testing.T) {
|
||||
t.Run("set same pointer value", func(t *testing.T) {
|
||||
t.Skip("not yet implemented")
|
||||
|
||||
path, tearDown := setupConfigFile(t, emptyConfig)
|
||||
defer tearDown()
|
||||
|
||||
fs, err := config.NewFileStore(path, false)
|
||||
require.NoError(t, err)
|
||||
defer fs.Close()
|
||||
|
||||
_, err = fs.Set(fs.Get())
|
||||
if assert.Error(t, err) {
|
||||
assert.EqualError(t, err, "old configuration modified instead of cloning")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("defaults required", func(t *testing.T) {
|
||||
path, tearDown := setupConfigFile(t, minimalConfig)
|
||||
defer tearDown()
|
||||
|
||||
fs, err := config.NewFileStore(path, false)
|
||||
require.NoError(t, err)
|
||||
defer fs.Close()
|
||||
|
||||
oldCfg := fs.Get()
|
||||
|
||||
newCfg := &model.Config{}
|
||||
|
||||
retCfg, err := fs.Set(newCfg)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, oldCfg, retCfg)
|
||||
|
||||
assert.Equal(t, model.SERVICE_SETTINGS_DEFAULT_SITE_URL, *fs.Get().ServiceSettings.SiteURL)
|
||||
})
|
||||
|
||||
t.Run("desanitization required", func(t *testing.T) {
|
||||
path, tearDown := setupConfigFile(t, ldapConfig)
|
||||
defer tearDown()
|
||||
|
||||
fs, err := config.NewFileStore(path, false)
|
||||
require.NoError(t, err)
|
||||
defer fs.Close()
|
||||
|
||||
oldCfg := fs.Get()
|
||||
|
||||
newCfg := &model.Config{}
|
||||
newCfg.LdapSettings.BindPassword = sToP(model.FAKE_SETTING)
|
||||
|
||||
retCfg, err := fs.Set(newCfg)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, oldCfg, retCfg)
|
||||
|
||||
assert.Equal(t, "password", *fs.Get().LdapSettings.BindPassword)
|
||||
})
|
||||
|
||||
t.Run("invalid", func(t *testing.T) {
|
||||
path, tearDown := setupConfigFile(t, emptyConfig)
|
||||
defer tearDown()
|
||||
|
||||
fs, err := config.NewFileStore(path, false)
|
||||
require.NoError(t, err)
|
||||
defer fs.Close()
|
||||
|
||||
newCfg := &model.Config{}
|
||||
newCfg.ServiceSettings.SiteURL = sToP("invalid")
|
||||
|
||||
_, err = fs.Set(newCfg)
|
||||
if assert.Error(t, err) {
|
||||
assert.EqualError(t, err, "new configuration is invalid: Config.IsValid: model.config.is_valid.site_url.app_error, ")
|
||||
}
|
||||
|
||||
assert.Equal(t, model.SERVICE_SETTINGS_DEFAULT_SITE_URL, *fs.Get().ServiceSettings.SiteURL)
|
||||
})
|
||||
|
||||
t.Run("read-only", func(t *testing.T) {
|
||||
path, tearDown := setupConfigFile(t, readOnlyConfig)
|
||||
defer tearDown()
|
||||
|
||||
fs, err := config.NewFileStore(path, false)
|
||||
require.NoError(t, err)
|
||||
defer fs.Close()
|
||||
|
||||
newCfg := &model.Config{}
|
||||
|
||||
_, err = fs.Set(newCfg)
|
||||
if assert.Error(t, err) {
|
||||
assert.Equal(t, err, config.ErrReadOnlyConfiguration)
|
||||
}
|
||||
|
||||
assert.Equal(t, model.SERVICE_SETTINGS_DEFAULT_SITE_URL, *fs.Get().ServiceSettings.SiteURL)
|
||||
})
|
||||
|
||||
t.Run("persist failed", func(t *testing.T) {
|
||||
t.Skip("skipping persistence test inside Set")
|
||||
path, tearDown := setupConfigFile(t, emptyConfig)
|
||||
defer tearDown()
|
||||
|
||||
fs, err := config.NewFileStore(path, false)
|
||||
require.NoError(t, err)
|
||||
defer fs.Close()
|
||||
|
||||
os.Chmod(path, 0500)
|
||||
|
||||
newCfg := &model.Config{}
|
||||
|
||||
_, err = fs.Set(newCfg)
|
||||
if assert.Error(t, err) {
|
||||
assert.True(t, strings.HasPrefix(err.Error(), "failed to persist: failed to write file"))
|
||||
}
|
||||
|
||||
assert.Equal(t, model.SERVICE_SETTINGS_DEFAULT_SITE_URL, *fs.Get().ServiceSettings.SiteURL)
|
||||
})
|
||||
|
||||
t.Run("listeners notified", func(t *testing.T) {
|
||||
path, tearDown := setupConfigFile(t, emptyConfig)
|
||||
defer tearDown()
|
||||
|
||||
fs, err := config.NewFileStore(path, false)
|
||||
require.NoError(t, err)
|
||||
defer fs.Close()
|
||||
|
||||
oldCfg := fs.Get()
|
||||
|
||||
called := make(chan bool, 1)
|
||||
callback := func(oldfg, newCfg *model.Config) {
|
||||
called <- true
|
||||
}
|
||||
fs.AddListener(callback)
|
||||
|
||||
newCfg := &model.Config{}
|
||||
|
||||
retCfg, err := fs.Set(newCfg)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, oldCfg, retCfg)
|
||||
|
||||
select {
|
||||
case <-called:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("callback should have been called when config written")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("watcher restarted", func(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping watcher test in short mode")
|
||||
}
|
||||
|
||||
path, tearDown := setupConfigFile(t, emptyConfig)
|
||||
defer tearDown()
|
||||
|
||||
fs, err := config.NewFileStore(path, true)
|
||||
require.NoError(t, err)
|
||||
defer fs.Close()
|
||||
|
||||
_, err = fs.Set(&model.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Let the initial call to invokeConfigListeners finish.
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
called := make(chan bool, 1)
|
||||
callback := func(oldfg, newCfg *model.Config) {
|
||||
called <- true
|
||||
}
|
||||
fs.AddListener(callback)
|
||||
|
||||
// Rewrite the config to the file on disk
|
||||
cfgData, err := config.MarshalConfig(emptyConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
ioutil.WriteFile(path, cfgData, 0644)
|
||||
select {
|
||||
case <-called:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("callback should have been called when config written")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFileStoreLoad(t *testing.T) {
|
||||
t.Run("file no longer exists", func(t *testing.T) {
|
||||
path, tearDown := setupConfigFile(t, emptyConfig)
|
||||
defer tearDown()
|
||||
|
||||
fs, err := config.NewFileStore(path, false)
|
||||
require.NoError(t, err)
|
||||
defer fs.Close()
|
||||
|
||||
os.Remove(path)
|
||||
|
||||
err = fs.Load()
|
||||
require.NoError(t, err)
|
||||
assertFileNotEqualsConfig(t, emptyConfig, path)
|
||||
})
|
||||
|
||||
t.Run("honour environment", func(t *testing.T) {
|
||||
path, tearDown := setupConfigFile(t, minimalConfig)
|
||||
defer tearDown()
|
||||
|
||||
fs, err := config.NewFileStore(path, false)
|
||||
require.NoError(t, err)
|
||||
defer fs.Close()
|
||||
|
||||
os.Setenv("MM_SERVICESETTINGS_SITEURL", "http://override")
|
||||
|
||||
err = fs.Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "http://override", *fs.Get().ServiceSettings.SiteURL)
|
||||
assert.Equal(t, map[string]interface{}{"ServiceSettings": map[string]interface{}{"SiteURL": true}}, fs.GetEnvironmentOverrides())
|
||||
})
|
||||
|
||||
t.Run("invalid", func(t *testing.T) {
|
||||
path, tearDown := setupConfigFile(t, emptyConfig)
|
||||
defer tearDown()
|
||||
|
||||
fs, err := config.NewFileStore(path, false)
|
||||
require.NoError(t, err)
|
||||
defer fs.Close()
|
||||
|
||||
cfgData, err := config.MarshalConfig(invalidConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
ioutil.WriteFile(path, cfgData, 0644)
|
||||
|
||||
err = fs.Load()
|
||||
if assert.Error(t, err) {
|
||||
assert.EqualError(t, err, "invalid config: Config.IsValid: model.config.is_valid.site_url.app_error, ")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fixes required", func(t *testing.T) {
|
||||
path, tearDown := setupConfigFile(t, fixesRequiredConfig)
|
||||
defer tearDown()
|
||||
|
||||
fs, err := config.NewFileStore(path, false)
|
||||
require.NoError(t, err)
|
||||
defer fs.Close()
|
||||
|
||||
err = fs.Load()
|
||||
require.NoError(t, err)
|
||||
assertFileNotEqualsConfig(t, fixesRequiredConfig, path)
|
||||
assert.Equal(t, "http://trailingslash", *fs.Get().ServiceSettings.SiteURL)
|
||||
assert.Equal(t, "/path/to/directory/", *fs.Get().FileSettings.Directory)
|
||||
assert.Equal(t, "en", *fs.Get().LocalizationSettings.DefaultServerLocale)
|
||||
assert.Equal(t, "en", *fs.Get().LocalizationSettings.DefaultClientLocale)
|
||||
})
|
||||
|
||||
t.Run("listeners notifed", func(t *testing.T) {
|
||||
path, tearDown := setupConfigFile(t, emptyConfig)
|
||||
defer tearDown()
|
||||
|
||||
fs, err := config.NewFileStore(path, false)
|
||||
require.NoError(t, err)
|
||||
defer fs.Close()
|
||||
|
||||
called := make(chan bool, 1)
|
||||
callback := func(oldfg, newCfg *model.Config) {
|
||||
called <- true
|
||||
}
|
||||
fs.AddListener(callback)
|
||||
|
||||
err = fs.Load()
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-called:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("callback should have been called when config loaded")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFileStoreWatcherEmitter(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping watcher test in short mode")
|
||||
}
|
||||
|
||||
t.Parallel()
|
||||
|
||||
path, tearDown := setupConfigFile(t, emptyConfig)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("disabled", func(t *testing.T) {
|
||||
fs, err := config.NewFileStore(path, false)
|
||||
require.NoError(t, err)
|
||||
defer fs.Close()
|
||||
|
||||
// Let the initial call to invokeConfigListeners finish.
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
called := make(chan bool, 1)
|
||||
callback := func(oldfg, newCfg *model.Config) {
|
||||
called <- true
|
||||
}
|
||||
fs.AddListener(callback)
|
||||
|
||||
// Rewrite the config to the file on disk
|
||||
cfgData, err := config.MarshalConfig(emptyConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
ioutil.WriteFile(path, cfgData, 0644)
|
||||
select {
|
||||
case <-called:
|
||||
t.Fatal("callback should not have been called since watching disabled")
|
||||
case <-time.After(1 * time.Second):
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("enabled", func(t *testing.T) {
|
||||
fs, err := config.NewFileStore(path, true)
|
||||
require.NoError(t, err)
|
||||
defer fs.Close()
|
||||
|
||||
called := make(chan bool, 1)
|
||||
callback := func(oldfg, newCfg *model.Config) {
|
||||
called <- true
|
||||
}
|
||||
fs.AddListener(callback)
|
||||
|
||||
// Rewrite the config to the file on disk
|
||||
cfgData, err := config.MarshalConfig(emptyConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
ioutil.WriteFile(path, cfgData, 0644)
|
||||
select {
|
||||
case <-called:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("callback should have been called when config written")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFileStoreSave(t *testing.T) {
|
||||
path, tearDown := setupConfigFile(t, minimalConfig)
|
||||
defer tearDown()
|
||||
|
||||
fs, err := config.NewFileStore(path, true)
|
||||
require.NoError(t, err)
|
||||
defer fs.Close()
|
||||
|
||||
newCfg := &model.Config{
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
SiteURL: sToP("http://new"),
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("set without save", func(t *testing.T) {
|
||||
_, err = fs.Set(newCfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = fs.Load()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "http://minimal", *fs.Get().ServiceSettings.SiteURL)
|
||||
})
|
||||
|
||||
t.Run("set with save", func(t *testing.T) {
|
||||
_, err = fs.Set(newCfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = fs.Save()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = fs.Load()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "http://new", *fs.Get().ServiceSettings.SiteURL)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFileStoreString(t *testing.T) {
|
||||
path, tearDown := setupConfigFile(t, emptyConfig)
|
||||
defer tearDown()
|
||||
|
||||
fs, err := config.NewFileStore(path, false)
|
||||
require.NoError(t, err)
|
||||
defer fs.Close()
|
||||
|
||||
assert.Equal(t, "file://"+path, fs.String())
|
||||
}
|
||||
17
config/main_test.go
Normal file
17
config/main_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/mattermost/mattermost-server/model"
|
||||
)
|
||||
|
||||
// MarshalConfig exposes the internal marshalConfig to tests only.
|
||||
func MarshalConfig(cfg *model.Config) ([]byte, error) {
|
||||
return marshalConfig(cfg)
|
||||
}
|
||||
|
||||
// UnmarshalConfig exposes the internal unmarshalConfig to tests only.
|
||||
func UnmarshalConfig(r io.Reader, allowEnvironmentOverrides bool) (*model.Config, map[string]interface{}, error) {
|
||||
return unmarshalConfig(r, allowEnvironmentOverrides)
|
||||
}
|
||||
103
config/memory.go
Normal file
103
config/memory.go
Normal file
@@ -0,0 +1,103 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/mattermost/mattermost-server/model"
|
||||
)
|
||||
|
||||
// memoryStore implements the Store interface. It is meant primarily for testing.
|
||||
type memoryStore struct {
|
||||
emitter
|
||||
|
||||
Config *model.Config
|
||||
EnvironmentOverrides map[string]interface{}
|
||||
|
||||
allowEnvironmentOverrides bool
|
||||
}
|
||||
|
||||
// NewMemoryStore creates a new memoryStore instance.
|
||||
func NewMemoryStore(allowEnvironmentOverrides bool) (*memoryStore, error) {
|
||||
defaultCfg := &model.Config{}
|
||||
defaultCfg.SetDefaults()
|
||||
|
||||
ms := &memoryStore{
|
||||
Config: defaultCfg,
|
||||
allowEnvironmentOverrides: allowEnvironmentOverrides,
|
||||
}
|
||||
|
||||
if err := ms.Load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ms, nil
|
||||
}
|
||||
|
||||
// Get fetches the current, cached configuration.
|
||||
func (ms *memoryStore) Get() *model.Config {
|
||||
return ms.Config
|
||||
}
|
||||
|
||||
// GetEnvironmentOverrides fetches the configuration fields overridden by environment variables.
|
||||
func (ms *memoryStore) GetEnvironmentOverrides() map[string]interface{} {
|
||||
return ms.EnvironmentOverrides
|
||||
}
|
||||
|
||||
// Set replaces the current configuration in its entirety.
|
||||
func (ms *memoryStore) Set(newCfg *model.Config) (*model.Config, error) {
|
||||
oldCfg := ms.Config
|
||||
|
||||
newCfg.SetDefaults()
|
||||
ms.Config = newCfg
|
||||
|
||||
return oldCfg, nil
|
||||
}
|
||||
|
||||
// serialize converts the given configuration into JSON bytes for persistence.
|
||||
func (ms *memoryStore) serialize(cfg *model.Config) ([]byte, error) {
|
||||
return json.MarshalIndent(cfg, "", " ")
|
||||
}
|
||||
|
||||
// Load applies environment overrides to the current config as if a re-load had occurred.
|
||||
func (ms *memoryStore) Load() (err error) {
|
||||
var cfgBytes []byte
|
||||
cfgBytes, err = ms.serialize(ms.Config)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to serialize config")
|
||||
}
|
||||
|
||||
f := ioutil.NopCloser(bytes.NewReader(cfgBytes))
|
||||
|
||||
allowEnvironmentOverrides := true
|
||||
loadedCfg, environmentOverrides, err := unmarshalConfig(f, allowEnvironmentOverrides)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to load config")
|
||||
}
|
||||
|
||||
ms.Config = loadedCfg
|
||||
ms.EnvironmentOverrides = environmentOverrides
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save does nothing, as there is no backing store.
|
||||
func (ms *memoryStore) Save() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// String returns a hard-coded description, as there is no backing store.
|
||||
func (ms *memoryStore) String() string {
|
||||
return "mock://"
|
||||
}
|
||||
|
||||
// Close does nothing for a mock store.
|
||||
func (ms *memoryStore) Close() error {
|
||||
return nil
|
||||
}
|
||||
41
config/store.go
Normal file
41
config/store.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost-server/model"
|
||||
)
|
||||
|
||||
// Listener is a callback function invoked when the configuration changes.
|
||||
type Listener func(oldConfig *model.Config, newConfig *model.Config)
|
||||
|
||||
// Store abstracts the act of getting and setting the configuration.
|
||||
type Store interface {
|
||||
// Get fetches the current, cached configuration.
|
||||
Get() *model.Config
|
||||
|
||||
// GetEnvironmentOverrides fetches the configuration fields overridden by environment variables.
|
||||
GetEnvironmentOverrides() map[string]interface{}
|
||||
|
||||
// Set replaces the current configuration in its entirety, without updating the backing store.
|
||||
Set(*model.Config) (*model.Config, error)
|
||||
|
||||
// Load updates the current configuration from the backing store, possibly initializing.
|
||||
Load() (err error)
|
||||
|
||||
// Save writes the current configuration to the backing store.
|
||||
Save() error
|
||||
|
||||
// AddListener adds a callback function to invoke when the configuration is modified.
|
||||
AddListener(listener Listener) string
|
||||
|
||||
// RemoveListener removes a callback function using an id returned from AddListener.
|
||||
RemoveListener(id string)
|
||||
|
||||
// String describes the backing store for the config.
|
||||
String() string
|
||||
|
||||
// Close cleans up resources associated with the store.
|
||||
Close() error
|
||||
}
|
||||
211
config/unmarshal.go
Normal file
211
config/unmarshal.go
Normal file
@@ -0,0 +1,211 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/viper"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/mattermost/mattermost-server/mlog"
|
||||
"github.com/mattermost/mattermost-server/model"
|
||||
"github.com/mattermost/mattermost-server/utils/jsonutils"
|
||||
)
|
||||
|
||||
// newViper creates an instance of viper.Viper configured for parsing a configuration.
|
||||
func newViper(allowEnvironmentOverrides bool) *viper.Viper {
|
||||
v := viper.New()
|
||||
|
||||
v.SetConfigType("json")
|
||||
|
||||
if allowEnvironmentOverrides {
|
||||
v.SetEnvPrefix("mm")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
v.AutomaticEnv()
|
||||
}
|
||||
|
||||
// Set zeroed defaults for all the config settings so that Viper knows what environment variables
|
||||
// it needs to be looking for. The correct defaults will later be applied using Config.SetDefaults.
|
||||
defaults := getDefaultsFromStruct(model.Config{})
|
||||
|
||||
for key, value := range defaults {
|
||||
if key == "PluginSettings.Plugins" || key == "PluginSettings.PluginStates" {
|
||||
continue
|
||||
}
|
||||
|
||||
v.SetDefault(key, value)
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func getDefaultsFromStruct(s interface{}) map[string]interface{} {
|
||||
return flattenStructToMap(structToMap(reflect.TypeOf(s)))
|
||||
}
|
||||
|
||||
// Converts a struct type into a nested map with keys matching the struct's fields and values
|
||||
// matching the zeroed value of the corresponding field.
|
||||
func structToMap(t reflect.Type) (out map[string]interface{}) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
mlog.Error("Panicked in structToMap. This should never happen.", mlog.Any("err", r))
|
||||
}
|
||||
}()
|
||||
|
||||
if t.Kind() != reflect.Struct {
|
||||
// Should never hit this, but this will prevent a panic if that does happen somehow
|
||||
return nil
|
||||
}
|
||||
|
||||
out = map[string]interface{}{}
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
|
||||
var value interface{}
|
||||
|
||||
switch field.Type.Kind() {
|
||||
case reflect.Struct:
|
||||
value = structToMap(field.Type)
|
||||
case reflect.Ptr:
|
||||
indirectType := field.Type.Elem()
|
||||
|
||||
if indirectType.Kind() == reflect.Struct {
|
||||
// Follow pointers to structs since we need to define defaults for their fields
|
||||
value = structToMap(indirectType)
|
||||
} else {
|
||||
value = nil
|
||||
}
|
||||
default:
|
||||
value = reflect.Zero(field.Type).Interface()
|
||||
}
|
||||
|
||||
out[field.Name] = value
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Flattens a nested map so that the result is a single map with keys corresponding to the
|
||||
// path through the original map. For example,
|
||||
// {
|
||||
// "a": {
|
||||
// "b": 1
|
||||
// },
|
||||
// "c": "sea"
|
||||
// }
|
||||
// would flatten to
|
||||
// {
|
||||
// "a.b": 1,
|
||||
// "c": "sea"
|
||||
// }
|
||||
func flattenStructToMap(in map[string]interface{}) map[string]interface{} {
|
||||
out := make(map[string]interface{})
|
||||
|
||||
for key, value := range in {
|
||||
if valueAsMap, ok := value.(map[string]interface{}); ok {
|
||||
sub := flattenStructToMap(valueAsMap)
|
||||
|
||||
for subKey, subValue := range sub {
|
||||
out[key+"."+subKey] = subValue
|
||||
}
|
||||
} else {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// marshalConfig converts the given configuration into JSON bytes for persistence.
|
||||
func marshalConfig(cfg *model.Config) ([]byte, error) {
|
||||
return json.MarshalIndent(cfg, "", " ")
|
||||
}
|
||||
|
||||
// unmarshalConfig unmarshals a raw configuration into a Config model and environment variable overrides.
|
||||
func unmarshalConfig(r io.Reader, allowEnvironmentOverrides bool) (*model.Config, map[string]interface{}, error) {
|
||||
// Pre-flight check the syntax of the configuration file to improve error messaging.
|
||||
configData, err := ioutil.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, nil, errors.Wrapf(err, "failed to read")
|
||||
}
|
||||
|
||||
var rawConfig interface{}
|
||||
if err = json.Unmarshal(configData, &rawConfig); err != nil {
|
||||
return nil, nil, jsonutils.HumanizeJsonError(err, configData)
|
||||
}
|
||||
|
||||
v := newViper(allowEnvironmentOverrides)
|
||||
if err := v.ReadConfig(bytes.NewReader(configData)); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var config model.Config
|
||||
unmarshalErr := v.Unmarshal(&config)
|
||||
// https://github.com/spf13/viper/issues/324
|
||||
// https://github.com/spf13/viper/issues/348
|
||||
if unmarshalErr == nil {
|
||||
config.PluginSettings.Plugins = make(map[string]map[string]interface{})
|
||||
unmarshalErr = v.UnmarshalKey("pluginsettings.plugins", &config.PluginSettings.Plugins)
|
||||
}
|
||||
if unmarshalErr == nil {
|
||||
config.PluginSettings.PluginStates = make(map[string]*model.PluginState)
|
||||
unmarshalErr = v.UnmarshalKey("pluginsettings.pluginstates", &config.PluginSettings.PluginStates)
|
||||
}
|
||||
|
||||
envConfig := v.EnvSettings()
|
||||
|
||||
var envErr error
|
||||
if envConfig, envErr = fixEnvSettingsCase(envConfig); envErr != nil {
|
||||
return nil, nil, envErr
|
||||
}
|
||||
|
||||
return &config, envConfig, unmarshalErr
|
||||
}
|
||||
|
||||
// Fixes the case of the environment variables sent back from Viper since Viper stores everything
|
||||
// as lower case.
|
||||
func fixEnvSettingsCase(in map[string]interface{}) (out map[string]interface{}, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
mlog.Error("Panicked in fixEnvSettingsCase. This should never happen.", mlog.Any("err", r))
|
||||
out = in
|
||||
}
|
||||
}()
|
||||
|
||||
var fixCase func(map[string]interface{}, reflect.Type) map[string]interface{}
|
||||
fixCase = func(in map[string]interface{}, t reflect.Type) map[string]interface{} {
|
||||
if t.Kind() != reflect.Struct {
|
||||
// Should never hit this, but this will prevent a panic if that does happen somehow
|
||||
return nil
|
||||
}
|
||||
|
||||
fixCaseOut := make(map[string]interface{}, len(in))
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
|
||||
key := field.Name
|
||||
if value, ok := in[strings.ToLower(key)]; ok {
|
||||
if valueAsMap, ok := value.(map[string]interface{}); ok {
|
||||
fixCaseOut[key] = fixCase(valueAsMap, field.Type)
|
||||
} else {
|
||||
fixCaseOut[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fixCaseOut
|
||||
}
|
||||
|
||||
out = fixCase(in, reflect.TypeOf(model.Config{}))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -5,7 +5,6 @@ package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -17,29 +16,41 @@ import (
|
||||
"github.com/mattermost/mattermost-server/utils"
|
||||
)
|
||||
|
||||
func TestConfig(t *testing.T) {
|
||||
utils.TranslationsPreInit()
|
||||
_, _, _, err := LoadConfig("config.json")
|
||||
require.Nil(t, err)
|
||||
func TestGetDefaultsFromStruct(t *testing.T) {
|
||||
s := struct {
|
||||
TestSettings struct {
|
||||
IntValue int
|
||||
BoolValue bool
|
||||
StringValue string
|
||||
}
|
||||
PointerToTestSettings *struct {
|
||||
Value int
|
||||
}
|
||||
}{}
|
||||
|
||||
defaults := getDefaultsFromStruct(s)
|
||||
|
||||
assert.Equal(t, defaults["TestSettings.IntValue"], 0)
|
||||
assert.Equal(t, defaults["TestSettings.BoolValue"], false)
|
||||
assert.Equal(t, defaults["TestSettings.StringValue"], "")
|
||||
assert.Equal(t, defaults["PointerToTestSettings.Value"], 0)
|
||||
assert.NotContains(t, defaults, "PointerToTestSettings")
|
||||
assert.Len(t, defaults, 4)
|
||||
}
|
||||
|
||||
func TestReadConfig(t *testing.T) {
|
||||
utils.TranslationsPreInit()
|
||||
|
||||
_, _, err := ReadConfig(bytes.NewReader([]byte(``)), false)
|
||||
func TestUnmarshalConfig(t *testing.T) {
|
||||
_, _, err := unmarshalConfig(bytes.NewReader([]byte(``)), false)
|
||||
require.EqualError(t, err, "parsing error at line 1, character 1: unexpected end of JSON input")
|
||||
|
||||
_, _, err = ReadConfig(bytes.NewReader([]byte(`
|
||||
_, _, err = unmarshalConfig(bytes.NewReader([]byte(`
|
||||
{
|
||||
malformed
|
||||
`)), false)
|
||||
require.EqualError(t, err, "parsing error at line 3, character 5: invalid character 'm' looking for beginning of object key string")
|
||||
}
|
||||
|
||||
func TestReadConfig_PluginSettings(t *testing.T) {
|
||||
utils.TranslationsPreInit()
|
||||
|
||||
config, _, err := ReadConfig(bytes.NewReader([]byte(`{
|
||||
func TestUnmarshalConfig_PluginSettings(t *testing.T) {
|
||||
config, _, err := unmarshalConfig(bytes.NewReader([]byte(`{
|
||||
"PluginSettings": {
|
||||
"Directory": "/temp/mattermost-plugins",
|
||||
"Plugins": {
|
||||
@@ -111,29 +122,7 @@ func TestReadConfig_PluginSettings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadConfig_ImageProxySettings(t *testing.T) {
|
||||
utils.TranslationsPreInit()
|
||||
|
||||
t.Run("deprecated settings should still be read properly", func(t *testing.T) {
|
||||
config, _, err := ReadConfig(bytes.NewReader([]byte(`{
|
||||
"ServiceSettings": {
|
||||
"ImageProxyType": "OldImageProxyType",
|
||||
"ImageProxyURL": "OldImageProxyURL",
|
||||
"ImageProxyOptions": "OldImageProxyOptions"
|
||||
}
|
||||
}`)), false)
|
||||
|
||||
require.Nil(t, err)
|
||||
|
||||
assert.Equal(t, model.NewString("OldImageProxyType"), config.ServiceSettings.DEPRECATED_DO_NOT_USE_ImageProxyType)
|
||||
assert.Equal(t, model.NewString("OldImageProxyURL"), config.ServiceSettings.DEPRECATED_DO_NOT_USE_ImageProxyURL)
|
||||
assert.Equal(t, model.NewString("OldImageProxyOptions"), config.ServiceSettings.DEPRECATED_DO_NOT_USE_ImageProxyOptions)
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigFromEnviroVars(t *testing.T) {
|
||||
utils.TranslationsPreInit()
|
||||
|
||||
config := `{
|
||||
"ServiceSettings": {
|
||||
"EnableCommands": true,
|
||||
@@ -166,16 +155,11 @@ func TestConfigFromEnviroVars(t *testing.T) {
|
||||
os.Setenv("MM_TEAMSETTINGS_SITENAME", "From Environment")
|
||||
os.Setenv("MM_TEAMSETTINGS_CUSTOMBRANDTEXT", "Custom Brand")
|
||||
|
||||
cfg, envCfg, err := ReadConfig(strings.NewReader(config), true)
|
||||
cfg, envCfg, err := unmarshalConfig(strings.NewReader(config), true)
|
||||
require.Nil(t, err)
|
||||
|
||||
if *cfg.TeamSettings.SiteName != "From Environment" {
|
||||
t.Fatal("Couldn't read config from environment var")
|
||||
}
|
||||
|
||||
if *cfg.TeamSettings.CustomBrandText != "Custom Brand" {
|
||||
t.Fatal("Couldn't read config from environment var")
|
||||
}
|
||||
assert.Equal(t, "From Environment", *cfg.TeamSettings.SiteName)
|
||||
assert.Equal(t, "Custom Brand", *cfg.TeamSettings.CustomBrandText)
|
||||
|
||||
if teamSettings, ok := envCfg["TeamSettings"]; !ok {
|
||||
t.Fatal("TeamSettings is missing from envConfig")
|
||||
@@ -194,12 +178,10 @@ func TestConfigFromEnviroVars(t *testing.T) {
|
||||
os.Unsetenv("MM_TEAMSETTINGS_SITENAME")
|
||||
os.Unsetenv("MM_TEAMSETTINGS_CUSTOMBRANDTEXT")
|
||||
|
||||
cfg, envCfg, err = ReadConfig(strings.NewReader(config), true)
|
||||
cfg, envCfg, err = unmarshalConfig(strings.NewReader(config), true)
|
||||
require.Nil(t, err)
|
||||
|
||||
if *cfg.TeamSettings.SiteName != "Mattermost" {
|
||||
t.Fatal("should have been reset")
|
||||
}
|
||||
assert.Equal(t, "Mattermost", *cfg.TeamSettings.SiteName)
|
||||
|
||||
if _, ok := envCfg["TeamSettings"]; ok {
|
||||
t.Fatal("TeamSettings should be missing from envConfig")
|
||||
@@ -210,7 +192,7 @@ func TestConfigFromEnviroVars(t *testing.T) {
|
||||
os.Setenv("MM_SERVICESETTINGS_ENABLECOMMANDS", "false")
|
||||
defer os.Unsetenv("MM_SERVICESETTINGS_ENABLECOMMANDS")
|
||||
|
||||
cfg, envCfg, err := ReadConfig(strings.NewReader(config), true)
|
||||
cfg, envCfg, err := unmarshalConfig(strings.NewReader(config), true)
|
||||
require.Nil(t, err)
|
||||
|
||||
if *cfg.ServiceSettings.EnableCommands {
|
||||
@@ -232,12 +214,10 @@ func TestConfigFromEnviroVars(t *testing.T) {
|
||||
os.Setenv("MM_SERVICESETTINGS_READTIMEOUT", "400")
|
||||
defer os.Unsetenv("MM_SERVICESETTINGS_READTIMEOUT")
|
||||
|
||||
cfg, envCfg, err := ReadConfig(strings.NewReader(config), true)
|
||||
cfg, envCfg, err := unmarshalConfig(strings.NewReader(config), true)
|
||||
require.Nil(t, err)
|
||||
|
||||
if *cfg.ServiceSettings.ReadTimeout != 400 {
|
||||
t.Fatal("Couldn't read config from environment var")
|
||||
}
|
||||
assert.Equal(t, 400, *cfg.ServiceSettings.ReadTimeout)
|
||||
|
||||
if serviceSettings, ok := envCfg["ServiceSettings"]; !ok {
|
||||
t.Fatal("ServiceSettings is missing from envConfig")
|
||||
@@ -254,12 +234,10 @@ func TestConfigFromEnviroVars(t *testing.T) {
|
||||
os.Setenv("MM_SERVICESETTINGS_SITEURL", "https://example.com")
|
||||
defer os.Unsetenv("MM_SERVICESETTINGS_SITEURL")
|
||||
|
||||
cfg, envCfg, err := ReadConfig(strings.NewReader(config), true)
|
||||
cfg, envCfg, err := unmarshalConfig(strings.NewReader(config), true)
|
||||
require.Nil(t, err)
|
||||
|
||||
if *cfg.ServiceSettings.SiteURL != "https://example.com" {
|
||||
t.Fatal("Couldn't read config from environment var")
|
||||
}
|
||||
assert.Equal(t, "https://example.com", *cfg.ServiceSettings.SiteURL)
|
||||
|
||||
if serviceSettings, ok := envCfg["ServiceSettings"]; !ok {
|
||||
t.Fatal("ServiceSettings is missing from envConfig")
|
||||
@@ -276,12 +254,10 @@ func TestConfigFromEnviroVars(t *testing.T) {
|
||||
os.Setenv("MM_SUPPORTSETTINGS_TERMSOFSERVICELINK", "")
|
||||
defer os.Unsetenv("MM_SUPPORTSETTINGS_TERMSOFSERVICELINK")
|
||||
|
||||
cfg, envCfg, err := ReadConfig(strings.NewReader(config), true)
|
||||
cfg, envCfg, err := unmarshalConfig(strings.NewReader(config), true)
|
||||
require.Nil(t, err)
|
||||
|
||||
if *cfg.SupportSettings.TermsOfServiceLink != "" {
|
||||
t.Fatal("Couldn't read empty TermsOfServiceLink from environment var")
|
||||
}
|
||||
assert.Empty(t, *cfg.SupportSettings.TermsOfServiceLink)
|
||||
|
||||
if supportSettings, ok := envCfg["SupportSettings"]; !ok {
|
||||
t.Fatal("SupportSettings is missing from envConfig")
|
||||
@@ -302,7 +278,7 @@ func TestConfigFromEnviroVars(t *testing.T) {
|
||||
defer os.Unsetenv("MM_PLUGINSETTINGS_DIRECTORY")
|
||||
defer os.Unsetenv("MM_PLUGINSETTINGS_CLIENTDIRECTORY")
|
||||
|
||||
cfg, envCfg, err := ReadConfig(strings.NewReader(config), true)
|
||||
cfg, envCfg, err := unmarshalConfig(strings.NewReader(config), true)
|
||||
require.Nil(t, err)
|
||||
|
||||
assert.Equal(t, false, *cfg.PluginSettings.Enable)
|
||||
@@ -331,7 +307,7 @@ func TestConfigFromEnviroVars(t *testing.T) {
|
||||
defer os.Unsetenv("MM_PLUGINSETTINGS_PLUGINS_JIRA_SECRET")
|
||||
defer os.Unsetenv("MM_PLUGINSETTINGS_PLUGINSTATES_JIRA_ENABLE")
|
||||
|
||||
cfg, envCfg, err := ReadConfig(strings.NewReader(config), true)
|
||||
cfg, envCfg, err := unmarshalConfig(strings.NewReader(config), true)
|
||||
require.Nil(t, err)
|
||||
|
||||
if pluginsJira, ok := cfg.PluginSettings.Plugins["jira"]; !ok {
|
||||
@@ -376,325 +352,22 @@ func TestConfigFromEnviroVars(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateLocales(t *testing.T) {
|
||||
func TestReadConfig_ImageProxySettings(t *testing.T) {
|
||||
utils.TranslationsPreInit()
|
||||
cfg, _, _, err := LoadConfig("config.json")
|
||||
require.Nil(t, err)
|
||||
|
||||
*cfg.LocalizationSettings.DefaultServerLocale = "en"
|
||||
*cfg.LocalizationSettings.DefaultClientLocale = "en"
|
||||
*cfg.LocalizationSettings.AvailableLocales = ""
|
||||
|
||||
// t.Logf("*cfg.LocalizationSettings.DefaultClientLocale: %+v", *cfg.LocalizationSettings.DefaultClientLocale)
|
||||
if err := ValidateLocales(cfg); err != nil {
|
||||
t.Fatal("Should have not returned an error")
|
||||
}
|
||||
|
||||
// validate DefaultServerLocale
|
||||
*cfg.LocalizationSettings.DefaultServerLocale = "junk"
|
||||
if err := ValidateLocales(cfg); err != nil {
|
||||
if *cfg.LocalizationSettings.DefaultServerLocale != "en" {
|
||||
t.Fatal("DefaultServerLocale should have assigned to en as a default value")
|
||||
}
|
||||
} else {
|
||||
t.Fatal("Should have returned an error validating DefaultServerLocale")
|
||||
}
|
||||
|
||||
*cfg.LocalizationSettings.DefaultServerLocale = ""
|
||||
if err := ValidateLocales(cfg); err != nil {
|
||||
if *cfg.LocalizationSettings.DefaultServerLocale != "en" {
|
||||
t.Fatal("DefaultServerLocale should have assigned to en as a default value")
|
||||
}
|
||||
} else {
|
||||
t.Fatal("Should have returned an error validating DefaultServerLocale")
|
||||
}
|
||||
|
||||
*cfg.LocalizationSettings.AvailableLocales = "en"
|
||||
*cfg.LocalizationSettings.DefaultServerLocale = "de"
|
||||
if err := ValidateLocales(cfg); err != nil {
|
||||
if strings.Contains(*cfg.LocalizationSettings.AvailableLocales, *cfg.LocalizationSettings.DefaultServerLocale) {
|
||||
t.Fatal("DefaultServerLocale should not be added to AvailableLocales")
|
||||
}
|
||||
t.Fatal("Should have not returned an error validating DefaultServerLocale")
|
||||
}
|
||||
|
||||
// validate DefaultClientLocale
|
||||
*cfg.LocalizationSettings.AvailableLocales = ""
|
||||
*cfg.LocalizationSettings.DefaultClientLocale = "junk"
|
||||
if err := ValidateLocales(cfg); err != nil {
|
||||
if *cfg.LocalizationSettings.DefaultClientLocale != "en" {
|
||||
t.Fatal("DefaultClientLocale should have assigned to en as a default value")
|
||||
}
|
||||
} else {
|
||||
|
||||
t.Fatal("Should have returned an error validating DefaultClientLocale")
|
||||
}
|
||||
|
||||
*cfg.LocalizationSettings.DefaultClientLocale = ""
|
||||
if err := ValidateLocales(cfg); err != nil {
|
||||
if *cfg.LocalizationSettings.DefaultClientLocale != "en" {
|
||||
t.Fatal("DefaultClientLocale should have assigned to en as a default value")
|
||||
}
|
||||
} else {
|
||||
t.Fatal("Should have returned an error validating DefaultClientLocale")
|
||||
}
|
||||
|
||||
*cfg.LocalizationSettings.AvailableLocales = "en"
|
||||
*cfg.LocalizationSettings.DefaultClientLocale = "de"
|
||||
if err := ValidateLocales(cfg); err != nil {
|
||||
if !strings.Contains(*cfg.LocalizationSettings.AvailableLocales, *cfg.LocalizationSettings.DefaultClientLocale) {
|
||||
t.Fatal("DefaultClientLocale should have added to AvailableLocales")
|
||||
}
|
||||
} else {
|
||||
t.Fatal("Should have returned an error validating DefaultClientLocale")
|
||||
}
|
||||
|
||||
// validate AvailableLocales
|
||||
*cfg.LocalizationSettings.DefaultServerLocale = "en"
|
||||
*cfg.LocalizationSettings.DefaultClientLocale = "en"
|
||||
*cfg.LocalizationSettings.AvailableLocales = "junk"
|
||||
if err := ValidateLocales(cfg); err != nil {
|
||||
if *cfg.LocalizationSettings.AvailableLocales != "" {
|
||||
t.Fatal("AvailableLocales should have assigned to empty string as a default value")
|
||||
}
|
||||
} else {
|
||||
t.Fatal("Should have returned an error validating AvailableLocales")
|
||||
}
|
||||
|
||||
*cfg.LocalizationSettings.AvailableLocales = "en,de,junk"
|
||||
if err := ValidateLocales(cfg); err != nil {
|
||||
if *cfg.LocalizationSettings.AvailableLocales != "" {
|
||||
t.Fatal("AvailableLocales should have assigned to empty string as a default value")
|
||||
}
|
||||
} else {
|
||||
t.Fatal("Should have returned an error validating AvailableLocales")
|
||||
}
|
||||
|
||||
*cfg.LocalizationSettings.DefaultServerLocale = "fr"
|
||||
*cfg.LocalizationSettings.DefaultClientLocale = "de"
|
||||
*cfg.LocalizationSettings.AvailableLocales = "en"
|
||||
if err := ValidateLocales(cfg); err != nil {
|
||||
if strings.Contains(*cfg.LocalizationSettings.AvailableLocales, *cfg.LocalizationSettings.DefaultServerLocale) {
|
||||
t.Fatal("DefaultServerLocale should not be added to AvailableLocales")
|
||||
}
|
||||
if !strings.Contains(*cfg.LocalizationSettings.AvailableLocales, *cfg.LocalizationSettings.DefaultClientLocale) {
|
||||
t.Fatal("DefaultClientLocale should have added to AvailableLocales")
|
||||
}
|
||||
} else {
|
||||
t.Fatal("Should have returned an error validating AvailableLocales")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetClientConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := []struct {
|
||||
description string
|
||||
config *model.Config
|
||||
diagnosticId string
|
||||
license *model.License
|
||||
expectedFields map[string]string
|
||||
}{
|
||||
{
|
||||
"unlicensed",
|
||||
&model.Config{
|
||||
EmailSettings: model.EmailSettings{
|
||||
EmailNotificationContentsType: sToP(model.EMAIL_NOTIFICATION_CONTENTS_FULL),
|
||||
},
|
||||
ThemeSettings: model.ThemeSettings{
|
||||
// Ignored, since not licensed.
|
||||
AllowCustomThemes: bToP(false),
|
||||
},
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
WebsocketURL: sToP("ws://mattermost.example.com:8065"),
|
||||
WebsocketPort: iToP(80),
|
||||
WebsocketSecurePort: iToP(443),
|
||||
},
|
||||
},
|
||||
"",
|
||||
nil,
|
||||
map[string]string{
|
||||
"DiagnosticId": "",
|
||||
"EmailNotificationContentsType": "full",
|
||||
"AllowCustomThemes": "true",
|
||||
"EnforceMultifactorAuthentication": "false",
|
||||
"WebsocketURL": "ws://mattermost.example.com:8065",
|
||||
"WebsocketPort": "80",
|
||||
"WebsocketSecurePort": "443",
|
||||
},
|
||||
},
|
||||
{
|
||||
"licensed, but not for theme management",
|
||||
&model.Config{
|
||||
EmailSettings: model.EmailSettings{
|
||||
EmailNotificationContentsType: sToP(model.EMAIL_NOTIFICATION_CONTENTS_FULL),
|
||||
},
|
||||
ThemeSettings: model.ThemeSettings{
|
||||
// Ignored, since not licensed.
|
||||
AllowCustomThemes: bToP(false),
|
||||
},
|
||||
},
|
||||
"tag1",
|
||||
&model.License{
|
||||
Features: &model.Features{
|
||||
ThemeManagement: bToP(false),
|
||||
},
|
||||
},
|
||||
map[string]string{
|
||||
"DiagnosticId": "tag1",
|
||||
"EmailNotificationContentsType": "full",
|
||||
"AllowCustomThemes": "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
"licensed for theme management",
|
||||
&model.Config{
|
||||
EmailSettings: model.EmailSettings{
|
||||
EmailNotificationContentsType: sToP(model.EMAIL_NOTIFICATION_CONTENTS_FULL),
|
||||
},
|
||||
ThemeSettings: model.ThemeSettings{
|
||||
AllowCustomThemes: bToP(false),
|
||||
},
|
||||
},
|
||||
"tag2",
|
||||
&model.License{
|
||||
Features: &model.Features{
|
||||
ThemeManagement: bToP(true),
|
||||
},
|
||||
},
|
||||
map[string]string{
|
||||
"DiagnosticId": "tag2",
|
||||
"EmailNotificationContentsType": "full",
|
||||
"AllowCustomThemes": "false",
|
||||
},
|
||||
},
|
||||
{
|
||||
"licensed for enforcement",
|
||||
&model.Config{
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
EnforceMultifactorAuthentication: bToP(true),
|
||||
},
|
||||
},
|
||||
"tag1",
|
||||
&model.License{
|
||||
Features: &model.Features{
|
||||
MFA: bToP(true),
|
||||
},
|
||||
},
|
||||
map[string]string{
|
||||
"EnforceMultifactorAuthentication": "true",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
testCase := testCase
|
||||
t.Run(testCase.description, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCase.config.SetDefaults()
|
||||
if testCase.license != nil {
|
||||
testCase.license.Features.SetDefaults()
|
||||
t.Run("deprecated settings should still be read properly", func(t *testing.T) {
|
||||
config, _, err := unmarshalConfig(bytes.NewReader([]byte(`{
|
||||
"ServiceSettings": {
|
||||
"ImageProxyType": "OldImageProxyType",
|
||||
"ImageProxyURL": "OldImageProxyURL",
|
||||
"ImageProxyOptions": "OldImageProxyOptions"
|
||||
}
|
||||
}`)), false)
|
||||
|
||||
configMap := GenerateClientConfig(testCase.config, testCase.diagnosticId, testCase.license)
|
||||
for expectedField, expectedValue := range testCase.expectedFields {
|
||||
actualValue, ok := configMap[expectedField]
|
||||
if assert.True(t, ok, fmt.Sprintf("config does not contain %v", expectedField)) {
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLimitedClientConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := []struct {
|
||||
description string
|
||||
config *model.Config
|
||||
diagnosticId string
|
||||
license *model.License
|
||||
expectedFields map[string]string
|
||||
}{
|
||||
{
|
||||
"unlicensed",
|
||||
&model.Config{
|
||||
EmailSettings: model.EmailSettings{
|
||||
EmailNotificationContentsType: sToP(model.EMAIL_NOTIFICATION_CONTENTS_FULL),
|
||||
},
|
||||
ThemeSettings: model.ThemeSettings{
|
||||
// Ignored, since not licensed.
|
||||
AllowCustomThemes: bToP(false),
|
||||
},
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
WebsocketURL: sToP("ws://mattermost.example.com:8065"),
|
||||
WebsocketPort: iToP(80),
|
||||
WebsocketSecurePort: iToP(443),
|
||||
},
|
||||
},
|
||||
"",
|
||||
nil,
|
||||
map[string]string{
|
||||
"DiagnosticId": "",
|
||||
"EnforceMultifactorAuthentication": "false",
|
||||
"WebsocketURL": "ws://mattermost.example.com:8065",
|
||||
"WebsocketPort": "80",
|
||||
"WebsocketSecurePort": "443",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
testCase := testCase
|
||||
t.Run(testCase.description, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCase.config.SetDefaults()
|
||||
if testCase.license != nil {
|
||||
testCase.license.Features.SetDefaults()
|
||||
}
|
||||
|
||||
configMap := GenerateLimitedClientConfig(testCase.config, testCase.diagnosticId, testCase.license)
|
||||
for expectedField, expectedValue := range testCase.expectedFields {
|
||||
actualValue, ok := configMap[expectedField]
|
||||
if assert.True(t, ok, fmt.Sprintf("config does not contain %v", expectedField)) {
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func sToP(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func bToP(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
func iToP(i int) *int {
|
||||
return &i
|
||||
}
|
||||
|
||||
func TestGetDefaultsFromStruct(t *testing.T) {
|
||||
s := struct {
|
||||
TestSettings struct {
|
||||
IntValue int
|
||||
BoolValue bool
|
||||
StringValue string
|
||||
}
|
||||
PointerToTestSettings *struct {
|
||||
Value int
|
||||
}
|
||||
}{}
|
||||
|
||||
defaults := getDefaultsFromStruct(s)
|
||||
|
||||
assert.Equal(t, defaults["TestSettings.IntValue"], 0)
|
||||
assert.Equal(t, defaults["TestSettings.BoolValue"], false)
|
||||
assert.Equal(t, defaults["TestSettings.StringValue"], "")
|
||||
assert.Equal(t, defaults["PointerToTestSettings.Value"], 0)
|
||||
assert.NotContains(t, defaults, "PointerToTestSettings")
|
||||
assert.Len(t, defaults, 4)
|
||||
require.Nil(t, err)
|
||||
|
||||
assert.Equal(t, model.NewString("OldImageProxyType"), config.ServiceSettings.DEPRECATED_DO_NOT_USE_ImageProxyType)
|
||||
assert.Equal(t, model.NewString("OldImageProxyURL"), config.ServiceSettings.DEPRECATED_DO_NOT_USE_ImageProxyURL)
|
||||
assert.Equal(t, model.NewString("OldImageProxyOptions"), config.ServiceSettings.DEPRECATED_DO_NOT_USE_ImageProxyOptions)
|
||||
})
|
||||
}
|
||||
133
config/utils.go
Normal file
133
config/utils.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost-server/mlog"
|
||||
"github.com/mattermost/mattermost-server/model"
|
||||
"github.com/mattermost/mattermost-server/utils"
|
||||
)
|
||||
|
||||
// desanitize replaces fake settings with their actual values.
|
||||
func desanitize(actual, target *model.Config) {
|
||||
if target.LdapSettings.BindPassword != nil && *target.LdapSettings.BindPassword == model.FAKE_SETTING {
|
||||
*target.LdapSettings.BindPassword = *actual.LdapSettings.BindPassword
|
||||
}
|
||||
|
||||
if *target.FileSettings.PublicLinkSalt == model.FAKE_SETTING {
|
||||
*target.FileSettings.PublicLinkSalt = *actual.FileSettings.PublicLinkSalt
|
||||
}
|
||||
if *target.FileSettings.AmazonS3SecretAccessKey == model.FAKE_SETTING {
|
||||
target.FileSettings.AmazonS3SecretAccessKey = actual.FileSettings.AmazonS3SecretAccessKey
|
||||
}
|
||||
|
||||
if *target.EmailSettings.InviteSalt == model.FAKE_SETTING {
|
||||
target.EmailSettings.InviteSalt = actual.EmailSettings.InviteSalt
|
||||
}
|
||||
if *target.EmailSettings.SMTPPassword == model.FAKE_SETTING {
|
||||
target.EmailSettings.SMTPPassword = actual.EmailSettings.SMTPPassword
|
||||
}
|
||||
|
||||
if *target.GitLabSettings.Secret == model.FAKE_SETTING {
|
||||
target.GitLabSettings.Secret = actual.GitLabSettings.Secret
|
||||
}
|
||||
|
||||
if *target.SqlSettings.DataSource == model.FAKE_SETTING {
|
||||
*target.SqlSettings.DataSource = *actual.SqlSettings.DataSource
|
||||
}
|
||||
if *target.SqlSettings.AtRestEncryptKey == model.FAKE_SETTING {
|
||||
target.SqlSettings.AtRestEncryptKey = actual.SqlSettings.AtRestEncryptKey
|
||||
}
|
||||
|
||||
if *target.ElasticsearchSettings.Password == model.FAKE_SETTING {
|
||||
*target.ElasticsearchSettings.Password = *actual.ElasticsearchSettings.Password
|
||||
}
|
||||
|
||||
target.SqlSettings.DataSourceReplicas = make([]string, len(actual.SqlSettings.DataSourceReplicas))
|
||||
for i := range target.SqlSettings.DataSourceReplicas {
|
||||
target.SqlSettings.DataSourceReplicas[i] = actual.SqlSettings.DataSourceReplicas[i]
|
||||
}
|
||||
|
||||
target.SqlSettings.DataSourceSearchReplicas = make([]string, len(actual.SqlSettings.DataSourceReplicas))
|
||||
for i := range target.SqlSettings.DataSourceSearchReplicas {
|
||||
target.SqlSettings.DataSourceSearchReplicas[i] = actual.SqlSettings.DataSourceSearchReplicas[i]
|
||||
}
|
||||
}
|
||||
|
||||
// fixConfig patches invalid or missing data in the configuration, returning true if changed.
|
||||
func fixConfig(cfg *model.Config) bool {
|
||||
changed := false
|
||||
|
||||
// Ensure SiteURL has no trailing slash.
|
||||
if strings.HasSuffix(*cfg.ServiceSettings.SiteURL, "/") {
|
||||
*cfg.ServiceSettings.SiteURL = strings.TrimRight(*cfg.ServiceSettings.SiteURL, "/")
|
||||
changed = true
|
||||
}
|
||||
|
||||
// Ensure the directory for a local file store has a trailing slash.
|
||||
if *cfg.FileSettings.DriverName == model.IMAGE_DRIVER_LOCAL {
|
||||
if !strings.HasSuffix(*cfg.FileSettings.Directory, "/") {
|
||||
*cfg.FileSettings.Directory += "/"
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if FixInvalidLocales(cfg) {
|
||||
changed = true
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
// FixInvalidLocales checks and corrects the given config for invalid locale-related settings.
|
||||
//
|
||||
// Ideally, this function would be completely internal, but it's currently exposed to allow the cli
|
||||
// to test the config change before allowing the save.
|
||||
func FixInvalidLocales(cfg *model.Config) bool {
|
||||
var changed bool
|
||||
|
||||
locales := utils.GetSupportedLocales()
|
||||
if _, ok := locales[*cfg.LocalizationSettings.DefaultServerLocale]; !ok {
|
||||
*cfg.LocalizationSettings.DefaultServerLocale = model.DEFAULT_LOCALE
|
||||
mlog.Warn("DefaultServerLocale must be one of the supported locales. Setting DefaultServerLocale to en as default value.")
|
||||
changed = true
|
||||
}
|
||||
|
||||
if _, ok := locales[*cfg.LocalizationSettings.DefaultClientLocale]; !ok {
|
||||
*cfg.LocalizationSettings.DefaultClientLocale = model.DEFAULT_LOCALE
|
||||
mlog.Warn("DefaultClientLocale must be one of the supported locales. Setting DefaultClientLocale to en as default value.")
|
||||
changed = true
|
||||
}
|
||||
|
||||
if len(*cfg.LocalizationSettings.AvailableLocales) > 0 {
|
||||
isDefaultClientLocaleInAvailableLocales := false
|
||||
for _, word := range strings.Split(*cfg.LocalizationSettings.AvailableLocales, ",") {
|
||||
if _, ok := locales[word]; !ok {
|
||||
*cfg.LocalizationSettings.AvailableLocales = ""
|
||||
isDefaultClientLocaleInAvailableLocales = true
|
||||
mlog.Warn("AvailableLocales must include DefaultClientLocale. Setting AvailableLocales to all locales as default value.")
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
|
||||
if word == *cfg.LocalizationSettings.DefaultClientLocale {
|
||||
isDefaultClientLocaleInAvailableLocales = true
|
||||
}
|
||||
}
|
||||
|
||||
availableLocales := *cfg.LocalizationSettings.AvailableLocales
|
||||
|
||||
if !isDefaultClientLocaleInAvailableLocales {
|
||||
availableLocales += "," + *cfg.LocalizationSettings.DefaultClientLocale
|
||||
mlog.Warn("Adding DefaultClientLocale to AvailableLocales.")
|
||||
changed = true
|
||||
}
|
||||
|
||||
*cfg.LocalizationSettings.AvailableLocales = strings.Join(utils.RemoveDuplicatesFromStringArray(strings.Split(availableLocales, ",")), ",")
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
158
config/utils_test.go
Normal file
158
config/utils_test.go
Normal file
@@ -0,0 +1,158 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mattermost/mattermost-server/model"
|
||||
"github.com/mattermost/mattermost-server/utils"
|
||||
)
|
||||
|
||||
func TestDesanitize(t *testing.T) {
|
||||
actual := &model.Config{}
|
||||
actual.SetDefaults()
|
||||
|
||||
// These setting should be ignored
|
||||
actual.LdapSettings.Enable = bToP(false)
|
||||
actual.FileSettings.DriverName = sToP("s3")
|
||||
|
||||
// These settings should be desanitized into target.
|
||||
actual.LdapSettings.BindPassword = sToP("bind_password")
|
||||
actual.FileSettings.PublicLinkSalt = sToP("public_link_salt")
|
||||
actual.FileSettings.AmazonS3SecretAccessKey = sToP("amazon_s3_secret_access_key")
|
||||
actual.EmailSettings.InviteSalt = sToP("invite_salt")
|
||||
actual.EmailSettings.SMTPPassword = sToP("smtp_password")
|
||||
actual.GitLabSettings.Secret = sToP("secret")
|
||||
actual.SqlSettings.DataSource = sToP("data_source")
|
||||
actual.SqlSettings.AtRestEncryptKey = sToP("at_rest_encrypt_key")
|
||||
actual.ElasticsearchSettings.Password = sToP("password")
|
||||
actual.SqlSettings.DataSourceReplicas = append(actual.SqlSettings.DataSourceReplicas, "replica0")
|
||||
actual.SqlSettings.DataSourceReplicas = append(actual.SqlSettings.DataSourceReplicas, "replica1")
|
||||
actual.SqlSettings.DataSourceSearchReplicas = append(actual.SqlSettings.DataSourceSearchReplicas, "search_replica0")
|
||||
actual.SqlSettings.DataSourceSearchReplicas = append(actual.SqlSettings.DataSourceSearchReplicas, "search_replica1")
|
||||
|
||||
target := &model.Config{}
|
||||
target.SetDefaults()
|
||||
|
||||
// These setting should be ignored
|
||||
target.LdapSettings.Enable = bToP(true)
|
||||
target.FileSettings.DriverName = sToP("file")
|
||||
|
||||
// These settings should be updated from actual
|
||||
target.LdapSettings.BindPassword = sToP(model.FAKE_SETTING)
|
||||
target.FileSettings.PublicLinkSalt = sToP(model.FAKE_SETTING)
|
||||
target.FileSettings.AmazonS3SecretAccessKey = sToP(model.FAKE_SETTING)
|
||||
target.EmailSettings.InviteSalt = sToP(model.FAKE_SETTING)
|
||||
target.EmailSettings.SMTPPassword = sToP(model.FAKE_SETTING)
|
||||
target.GitLabSettings.Secret = sToP(model.FAKE_SETTING)
|
||||
target.SqlSettings.DataSource = sToP(model.FAKE_SETTING)
|
||||
target.SqlSettings.AtRestEncryptKey = sToP(model.FAKE_SETTING)
|
||||
target.ElasticsearchSettings.Password = sToP(model.FAKE_SETTING)
|
||||
target.SqlSettings.DataSourceReplicas = append(target.SqlSettings.DataSourceReplicas, "old_replica0")
|
||||
target.SqlSettings.DataSourceSearchReplicas = append(target.SqlSettings.DataSourceReplicas, "old_search_replica0")
|
||||
|
||||
actual_clone := actual.Clone()
|
||||
desanitize(actual, target)
|
||||
assert.Equal(t, actual_clone, actual, "actual should not have been changed")
|
||||
|
||||
// Verify the settings that should have been left untouched in target
|
||||
assert.True(t, *target.LdapSettings.Enable, "LdapSettings.Enable should not have changed")
|
||||
assert.Equal(t, "file", *target.FileSettings.DriverName, "FileSettings.DriverName should not have been changed")
|
||||
|
||||
// Verify the settings that should have been desanitized into target
|
||||
assert.Equal(t, *actual.LdapSettings.BindPassword, *target.LdapSettings.BindPassword)
|
||||
assert.Equal(t, *actual.FileSettings.PublicLinkSalt, *target.FileSettings.PublicLinkSalt)
|
||||
assert.Equal(t, *actual.FileSettings.AmazonS3SecretAccessKey, *target.FileSettings.AmazonS3SecretAccessKey)
|
||||
assert.Equal(t, *actual.EmailSettings.InviteSalt, *target.EmailSettings.InviteSalt)
|
||||
assert.Equal(t, *actual.EmailSettings.SMTPPassword, *target.EmailSettings.SMTPPassword)
|
||||
assert.Equal(t, *actual.GitLabSettings.Secret, *target.GitLabSettings.Secret)
|
||||
assert.Equal(t, *actual.SqlSettings.DataSource, *target.SqlSettings.DataSource)
|
||||
assert.Equal(t, *actual.SqlSettings.AtRestEncryptKey, *target.SqlSettings.AtRestEncryptKey)
|
||||
assert.Equal(t, *actual.ElasticsearchSettings.Password, *target.ElasticsearchSettings.Password)
|
||||
assert.Equal(t, actual.SqlSettings.DataSourceReplicas, target.SqlSettings.DataSourceReplicas)
|
||||
assert.Equal(t, actual.SqlSettings.DataSourceSearchReplicas, target.SqlSettings.DataSourceSearchReplicas)
|
||||
}
|
||||
|
||||
func TestFixInvalidLocales(t *testing.T) {
|
||||
utils.TranslationsPreInit()
|
||||
|
||||
cfg := &model.Config{}
|
||||
cfg.SetDefaults()
|
||||
|
||||
*cfg.LocalizationSettings.DefaultServerLocale = "en"
|
||||
*cfg.LocalizationSettings.DefaultClientLocale = "en"
|
||||
*cfg.LocalizationSettings.AvailableLocales = ""
|
||||
|
||||
changed := FixInvalidLocales(cfg)
|
||||
assert.False(t, changed)
|
||||
|
||||
*cfg.LocalizationSettings.DefaultServerLocale = "junk"
|
||||
changed = FixInvalidLocales(cfg)
|
||||
assert.True(t, changed)
|
||||
assert.Equal(t, "en", *cfg.LocalizationSettings.DefaultServerLocale)
|
||||
|
||||
*cfg.LocalizationSettings.DefaultServerLocale = ""
|
||||
changed = FixInvalidLocales(cfg)
|
||||
assert.True(t, changed)
|
||||
assert.Equal(t, "en", *cfg.LocalizationSettings.DefaultServerLocale)
|
||||
|
||||
*cfg.LocalizationSettings.AvailableLocales = "en"
|
||||
*cfg.LocalizationSettings.DefaultServerLocale = "de"
|
||||
changed = FixInvalidLocales(cfg)
|
||||
assert.False(t, changed)
|
||||
assert.NotContains(t, *cfg.LocalizationSettings.AvailableLocales, *cfg.LocalizationSettings.DefaultServerLocale, "DefaultServerLocale should not be added to AvailableLocales")
|
||||
|
||||
*cfg.LocalizationSettings.AvailableLocales = ""
|
||||
*cfg.LocalizationSettings.DefaultClientLocale = "junk"
|
||||
changed = FixInvalidLocales(cfg)
|
||||
assert.True(t, changed)
|
||||
assert.Equal(t, "en", *cfg.LocalizationSettings.DefaultClientLocale)
|
||||
|
||||
*cfg.LocalizationSettings.DefaultClientLocale = ""
|
||||
changed = FixInvalidLocales(cfg)
|
||||
assert.True(t, changed)
|
||||
assert.Equal(t, "en", *cfg.LocalizationSettings.DefaultClientLocale)
|
||||
|
||||
*cfg.LocalizationSettings.AvailableLocales = "en"
|
||||
*cfg.LocalizationSettings.DefaultClientLocale = "de"
|
||||
changed = FixInvalidLocales(cfg)
|
||||
assert.True(t, changed)
|
||||
assert.Contains(t, *cfg.LocalizationSettings.AvailableLocales, *cfg.LocalizationSettings.DefaultServerLocale, "DefaultClientLocale should have been added to AvailableLocales")
|
||||
|
||||
// validate AvailableLocales
|
||||
*cfg.LocalizationSettings.DefaultServerLocale = "en"
|
||||
*cfg.LocalizationSettings.DefaultClientLocale = "en"
|
||||
*cfg.LocalizationSettings.AvailableLocales = "junk"
|
||||
changed = FixInvalidLocales(cfg)
|
||||
assert.True(t, changed)
|
||||
assert.Equal(t, "", *cfg.LocalizationSettings.AvailableLocales)
|
||||
|
||||
*cfg.LocalizationSettings.AvailableLocales = "en,de,junk"
|
||||
changed = FixInvalidLocales(cfg)
|
||||
assert.True(t, changed)
|
||||
assert.Equal(t, "", *cfg.LocalizationSettings.AvailableLocales)
|
||||
|
||||
*cfg.LocalizationSettings.DefaultServerLocale = "fr"
|
||||
*cfg.LocalizationSettings.DefaultClientLocale = "de"
|
||||
*cfg.LocalizationSettings.AvailableLocales = "en"
|
||||
changed = FixInvalidLocales(cfg)
|
||||
assert.True(t, changed)
|
||||
assert.NotContains(t, *cfg.LocalizationSettings.AvailableLocales, *cfg.LocalizationSettings.DefaultServerLocale, "DefaultServerLocale should not be added to AvailableLocales")
|
||||
assert.Contains(t, *cfg.LocalizationSettings.AvailableLocales, *cfg.LocalizationSettings.DefaultClientLocale, "DefaultClientLocale should have been added to AvailableLocales")
|
||||
}
|
||||
|
||||
func sToP(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func bToP(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
func iToP(i int) *int {
|
||||
return &i
|
||||
}
|
||||
82
config/watcher.go
Normal file
82
config/watcher.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/mattermost/mattermost-server/mlog"
|
||||
)
|
||||
|
||||
// watcher monitors a file for changes
|
||||
type watcher struct {
|
||||
emitter
|
||||
|
||||
fsWatcher *fsnotify.Watcher
|
||||
close chan struct{}
|
||||
closed chan struct{}
|
||||
}
|
||||
|
||||
// newWatcher creates a new instance of watcher to monitor for file changes.
|
||||
func newWatcher(path string, callback func()) (w *watcher, err error) {
|
||||
fsWatcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to create fsnotify watcher for %s", path)
|
||||
}
|
||||
|
||||
path = filepath.Clean(path)
|
||||
|
||||
// Watch the entire containing directory.
|
||||
configDir, _ := filepath.Split(path)
|
||||
if err := fsWatcher.Add(configDir); err != nil {
|
||||
if closeErr := fsWatcher.Close(); closeErr != nil {
|
||||
mlog.Error("failed to stop fsnotify watcher for %s", mlog.String("path", path), mlog.Err(closeErr))
|
||||
}
|
||||
return nil, errors.Wrapf(err, "failed to watch directory %s", configDir)
|
||||
}
|
||||
|
||||
w = &watcher{
|
||||
fsWatcher: fsWatcher,
|
||||
close: make(chan struct{}),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(w.closed)
|
||||
defer func() {
|
||||
if err := fsWatcher.Close(); err != nil {
|
||||
mlog.Error("failed to stop fsnotify watcher for %s", mlog.String("path", path))
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case event := <-fsWatcher.Events:
|
||||
// We only care about the given file.
|
||||
if filepath.Clean(event.Name) == path {
|
||||
if event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create {
|
||||
mlog.Info("Config file watcher detected a change", mlog.String("path", path))
|
||||
go callback()
|
||||
}
|
||||
}
|
||||
case err := <-fsWatcher.Errors:
|
||||
mlog.Error("Failed while watching config file", mlog.String("path", path), mlog.Err(err))
|
||||
case <-w.close:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (w *watcher) Close() error {
|
||||
close(w.close)
|
||||
<-w.closed
|
||||
|
||||
return nil
|
||||
}
|
||||
63
config/watcher_test.go
Normal file
63
config/watcher_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWatcherInvalidDirectory(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping watcher test in short mode")
|
||||
}
|
||||
|
||||
callback := func() {}
|
||||
_, err := newWatcher("/does/not/exist", callback)
|
||||
require.Error(t, err, "should have failed to watch a non-existent directory")
|
||||
}
|
||||
|
||||
func TestWatcher(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping watcher test in short mode")
|
||||
}
|
||||
|
||||
tempDir, err := ioutil.TempDir("", "TestWatcher")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
f, err := ioutil.TempFile(tempDir, "TestWatcher")
|
||||
require.NoError(t, err)
|
||||
defer f.Close()
|
||||
defer os.Remove(f.Name())
|
||||
|
||||
called := make(chan bool)
|
||||
callback := func() {
|
||||
called <- true
|
||||
}
|
||||
watcher, err := newWatcher(f.Name(), callback)
|
||||
require.NoError(t, err)
|
||||
defer watcher.Close()
|
||||
|
||||
// Write to a different file
|
||||
ioutil.WriteFile(filepath.Join(tempDir, "unrelated"), []byte("data"), 0644)
|
||||
select {
|
||||
case <-called:
|
||||
t.Fatal("callback should not have been called for unrelated file")
|
||||
case <-time.After(1 * time.Second):
|
||||
}
|
||||
|
||||
// Write to the watched file
|
||||
ioutil.WriteFile(f.Name(), []byte("data"), 0644)
|
||||
select {
|
||||
case <-called:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("callback should have been called when file written")
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ type LdapInterface interface {
|
||||
CheckPassword(id string, password string) *model.AppError
|
||||
CheckPasswordAuthData(authData string, password string) *model.AppError
|
||||
SwitchToLdap(userId, ldapId, ldapPassword string) *model.AppError
|
||||
ValidateFilter(filter string) *model.AppError
|
||||
StartSynchronizeJob(waitForJobToFinish bool) (*model.Job, *model.AppError)
|
||||
RunTest() *model.AppError
|
||||
GetAllLdapUsers() ([]*model.User, *model.AppError)
|
||||
|
||||
@@ -3182,6 +3182,10 @@
|
||||
"id": "app.notification.subject.notification.full",
|
||||
"translation": "[{{ .SiteName }}] Notification in {{ .TeamName}} on {{.Month}} {{.Day}}, {{.Year}}"
|
||||
},
|
||||
{
|
||||
"id": "app.save_config.app_error",
|
||||
"translation": "An error occurred saving the configuration"
|
||||
},
|
||||
{
|
||||
"id": "app.plugin.cluster.save_config.app_error",
|
||||
"translation": "The plugin configuration in your config.json file must be updated manually when using ReadOnlyConfig with clustering enabled."
|
||||
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-ldap/ldap"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -2529,6 +2531,12 @@ func (ls *LdapSettings) isValid() *AppError {
|
||||
if *ls.LoginIdAttribute == "" {
|
||||
return NewAppError("Config.IsValid", "model.config.is_valid.ldap_login_id", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if *ls.UserFilter != "" {
|
||||
if _, err := ldap.CompileFilter(*ls.UserFilter); err != nil {
|
||||
return NewAppError("ValidateFilter", "ent.ldap.validate_filter.app_error", nil, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -689,3 +689,156 @@ func TestImageProxySettingsIsValid(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLdapSettingsIsValid(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
Name string
|
||||
LdapSettings LdapSettings
|
||||
ExpectError bool
|
||||
}{
|
||||
{
|
||||
Name: "disabled",
|
||||
LdapSettings: LdapSettings{
|
||||
Enable: NewBool(false),
|
||||
},
|
||||
ExpectError: false,
|
||||
},
|
||||
{
|
||||
Name: "missing server",
|
||||
LdapSettings: LdapSettings{
|
||||
Enable: NewBool(true),
|
||||
LdapServer: NewString(""),
|
||||
BaseDN: NewString("basedn"),
|
||||
EmailAttribute: NewString("email"),
|
||||
UsernameAttribute: NewString("username"),
|
||||
IdAttribute: NewString("id"),
|
||||
LoginIdAttribute: NewString("loginid"),
|
||||
UserFilter: NewString(""),
|
||||
},
|
||||
ExpectError: true,
|
||||
},
|
||||
{
|
||||
Name: "empty user filter",
|
||||
LdapSettings: LdapSettings{
|
||||
Enable: NewBool(true),
|
||||
LdapServer: NewString("server"),
|
||||
BaseDN: NewString("basedn"),
|
||||
EmailAttribute: NewString("email"),
|
||||
UsernameAttribute: NewString("username"),
|
||||
IdAttribute: NewString("id"),
|
||||
LoginIdAttribute: NewString("loginid"),
|
||||
UserFilter: NewString(""),
|
||||
},
|
||||
ExpectError: false,
|
||||
},
|
||||
{
|
||||
Name: "valid user filter #1",
|
||||
LdapSettings: LdapSettings{
|
||||
Enable: NewBool(true),
|
||||
LdapServer: NewString("server"),
|
||||
BaseDN: NewString("basedn"),
|
||||
EmailAttribute: NewString("email"),
|
||||
UsernameAttribute: NewString("username"),
|
||||
IdAttribute: NewString("id"),
|
||||
LoginIdAttribute: NewString("loginid"),
|
||||
UserFilter: NewString("(property=value)"),
|
||||
},
|
||||
ExpectError: false,
|
||||
},
|
||||
{
|
||||
Name: "invalid user filter #1",
|
||||
LdapSettings: LdapSettings{
|
||||
Enable: NewBool(true),
|
||||
LdapServer: NewString("server"),
|
||||
BaseDN: NewString("basedn"),
|
||||
EmailAttribute: NewString("email"),
|
||||
UsernameAttribute: NewString("username"),
|
||||
IdAttribute: NewString("id"),
|
||||
LoginIdAttribute: NewString("loginid"),
|
||||
UserFilter: NewString("("),
|
||||
},
|
||||
ExpectError: true,
|
||||
},
|
||||
{
|
||||
Name: "invalid user filter #2",
|
||||
LdapSettings: LdapSettings{
|
||||
Enable: NewBool(true),
|
||||
LdapServer: NewString("server"),
|
||||
BaseDN: NewString("basedn"),
|
||||
EmailAttribute: NewString("email"),
|
||||
UsernameAttribute: NewString("username"),
|
||||
IdAttribute: NewString("id"),
|
||||
LoginIdAttribute: NewString("loginid"),
|
||||
UserFilter: NewString("()"),
|
||||
},
|
||||
ExpectError: true,
|
||||
},
|
||||
{
|
||||
Name: "valid user filter #2",
|
||||
LdapSettings: LdapSettings{
|
||||
Enable: NewBool(true),
|
||||
LdapServer: NewString("server"),
|
||||
BaseDN: NewString("basedn"),
|
||||
EmailAttribute: NewString("email"),
|
||||
UsernameAttribute: NewString("username"),
|
||||
IdAttribute: NewString("id"),
|
||||
LoginIdAttribute: NewString("loginid"),
|
||||
UserFilter: NewString("(&(property=value)(otherthing=othervalue))"),
|
||||
},
|
||||
ExpectError: false,
|
||||
},
|
||||
{
|
||||
Name: "valid user filter #3",
|
||||
LdapSettings: LdapSettings{
|
||||
Enable: NewBool(true),
|
||||
LdapServer: NewString("server"),
|
||||
BaseDN: NewString("basedn"),
|
||||
EmailAttribute: NewString("email"),
|
||||
UsernameAttribute: NewString("username"),
|
||||
IdAttribute: NewString("id"),
|
||||
LoginIdAttribute: NewString("loginid"),
|
||||
UserFilter: NewString("(&(property=value)(|(otherthing=othervalue)(other=thing)))"),
|
||||
},
|
||||
ExpectError: false,
|
||||
},
|
||||
{
|
||||
Name: "invalid user filter #3",
|
||||
LdapSettings: LdapSettings{
|
||||
Enable: NewBool(true),
|
||||
LdapServer: NewString("server"),
|
||||
BaseDN: NewString("basedn"),
|
||||
EmailAttribute: NewString("email"),
|
||||
UsernameAttribute: NewString("username"),
|
||||
IdAttribute: NewString("id"),
|
||||
LoginIdAttribute: NewString("loginid"),
|
||||
UserFilter: NewString("(&(property=value)(|(otherthing=othervalue)(other=thing))"),
|
||||
},
|
||||
ExpectError: true,
|
||||
},
|
||||
{
|
||||
Name: "invalid user filter #4",
|
||||
LdapSettings: LdapSettings{
|
||||
Enable: NewBool(true),
|
||||
LdapServer: NewString("server"),
|
||||
BaseDN: NewString("basedn"),
|
||||
EmailAttribute: NewString("email"),
|
||||
UsernameAttribute: NewString("username"),
|
||||
IdAttribute: NewString("id"),
|
||||
LoginIdAttribute: NewString("loginid"),
|
||||
UserFilter: NewString("(&(property=value)((otherthing=othervalue)(other=thing)))"),
|
||||
},
|
||||
ExpectError: true,
|
||||
},
|
||||
} {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
test.LdapSettings.SetDefaults()
|
||||
|
||||
err := test.LdapSettings.isValid()
|
||||
if test.ExpectError {
|
||||
assert.NotNil(t, err)
|
||||
} else {
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,11 @@ import (
|
||||
)
|
||||
|
||||
func TestMailConnectionFromConfig(t *testing.T) {
|
||||
cfg, _, _, err := config.LoadConfig("config.json")
|
||||
fs, err := config.NewFileStore("config.json", false)
|
||||
require.Nil(t, err)
|
||||
|
||||
cfg := fs.Get()
|
||||
|
||||
if conn, err := ConnectToSMTPServer(cfg); err != nil {
|
||||
t.Log(err)
|
||||
t.Fatal("Should connect to the STMP Server")
|
||||
@@ -44,9 +46,11 @@ func TestMailConnectionFromConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMailConnectionAdvanced(t *testing.T) {
|
||||
cfg, _, _, err := config.LoadConfig("config.json")
|
||||
fs, err := config.NewFileStore("config.json", false)
|
||||
require.Nil(t, err)
|
||||
|
||||
cfg := fs.Get()
|
||||
|
||||
if conn, err := ConnectToSMTPServerAdvanced(
|
||||
&SmtpConnectionInfo{
|
||||
ConnectionSecurity: *cfg.EmailSettings.ConnectionSecurity,
|
||||
@@ -94,10 +98,13 @@ func TestMailConnectionAdvanced(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSendMailUsingConfig(t *testing.T) {
|
||||
cfg, _, _, err := config.LoadConfig("config.json")
|
||||
require.Nil(t, err)
|
||||
utils.T = utils.GetUserTranslations("en")
|
||||
|
||||
fs, err := config.NewFileStore("config.json", false)
|
||||
require.Nil(t, err)
|
||||
|
||||
cfg := fs.Get()
|
||||
|
||||
var emailTo = "test@example.com"
|
||||
var emailSubject = "Testing this email"
|
||||
var emailBody = "This is a test from autobot"
|
||||
@@ -136,10 +143,13 @@ func TestSendMailUsingConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSendMailUsingConfigAdvanced(t *testing.T) {
|
||||
cfg, _, _, err := config.LoadConfig("config.json")
|
||||
require.Nil(t, err)
|
||||
utils.T = utils.GetUserTranslations("en")
|
||||
|
||||
fs, err := config.NewFileStore("config.json", false)
|
||||
require.Nil(t, err)
|
||||
|
||||
cfg := fs.Get()
|
||||
|
||||
var mimeTo = "test@example.com"
|
||||
var smtpTo = "test2@example.com"
|
||||
var from = mail.Address{Name: "Nobody", Address: "nobody@mattermost.com"}
|
||||
|
||||
Reference in New Issue
Block a user