Doug Lauder
2023-03-22 17:22:27 -04:00
коммит произвёл GitHub
родитель b61c096497
Коммит c943ed6859
13276 изменённых файлов: 1695615 добавлений и 223189 удалений

3
server/config/README.md Обычный файл
Просмотреть файл

@@ -0,0 +1,3 @@
# 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://docs.mattermost.com/configure/configuration-settings.html/

372
server/config/client.go Обычный файл
Просмотреть файл

@@ -0,0 +1,372 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package config
import (
"fmt"
"strconv"
"strings"
"github.com/mattermost/mattermost-server/v6/model"
)
// GenerateClientConfig renders the given configuration for a client.
func GenerateClientConfig(c *model.Config, telemetryID string, license *model.License) map[string]string {
props := GenerateLimitedClientConfig(c, telemetryID, license)
props["EnableCustomUserStatuses"] = strconv.FormatBool(*c.TeamSettings.EnableCustomUserStatuses)
props["EnableLastActiveTime"] = strconv.FormatBool(*c.TeamSettings.EnableLastActiveTime)
props["EnableUserDeactivation"] = strconv.FormatBool(*c.TeamSettings.EnableUserDeactivation)
props["RestrictDirectMessage"] = *c.TeamSettings.RestrictDirectMessage
props["TeammateNameDisplay"] = *c.TeamSettings.TeammateNameDisplay
props["LockTeammateNameDisplay"] = strconv.FormatBool(*c.TeamSettings.LockTeammateNameDisplay)
props["ExperimentalPrimaryTeam"] = *c.TeamSettings.ExperimentalPrimaryTeam
props["ExperimentalViewArchivedChannels"] = strconv.FormatBool(*c.TeamSettings.ExperimentalViewArchivedChannels)
props["EnableBotAccountCreation"] = strconv.FormatBool(*c.ServiceSettings.EnableBotAccountCreation)
props["EnableOAuthServiceProvider"] = strconv.FormatBool(*c.ServiceSettings.EnableOAuthServiceProvider)
props["GoogleDeveloperKey"] = *c.ServiceSettings.GoogleDeveloperKey
props["EnableIncomingWebhooks"] = strconv.FormatBool(*c.ServiceSettings.EnableIncomingWebhooks)
props["EnableOutgoingWebhooks"] = strconv.FormatBool(*c.ServiceSettings.EnableOutgoingWebhooks)
props["EnableCommands"] = strconv.FormatBool(*c.ServiceSettings.EnableCommands)
props["EnablePostUsernameOverride"] = strconv.FormatBool(*c.ServiceSettings.EnablePostUsernameOverride)
props["EnablePostIconOverride"] = strconv.FormatBool(*c.ServiceSettings.EnablePostIconOverride)
props["EnableUserAccessTokens"] = strconv.FormatBool(*c.ServiceSettings.EnableUserAccessTokens)
props["EnableLinkPreviews"] = strconv.FormatBool(*c.ServiceSettings.EnableLinkPreviews)
props["EnablePermalinkPreviews"] = strconv.FormatBool(*c.ServiceSettings.EnablePermalinkPreviews)
props["EnableTesting"] = strconv.FormatBool(*c.ServiceSettings.EnableTesting)
props["EnableDeveloper"] = strconv.FormatBool(*c.ServiceSettings.EnableDeveloper)
props["EnableClientPerformanceDebugging"] = strconv.FormatBool(*c.ServiceSettings.EnableClientPerformanceDebugging)
props["PostEditTimeLimit"] = fmt.Sprintf("%v", *c.ServiceSettings.PostEditTimeLimit)
props["MinimumHashtagLength"] = fmt.Sprintf("%v", *c.ServiceSettings.MinimumHashtagLength)
props["EnablePreviewFeatures"] = strconv.FormatBool(*c.ServiceSettings.EnablePreviewFeatures)
props["EnableTutorial"] = strconv.FormatBool(*c.ServiceSettings.EnableTutorial)
props["EnableOnboardingFlow"] = strconv.FormatBool(*c.ServiceSettings.EnableOnboardingFlow)
props["ExperimentalEnableDefaultChannelLeaveJoinMessages"] = strconv.FormatBool(*c.ServiceSettings.ExperimentalEnableDefaultChannelLeaveJoinMessages)
props["ExperimentalGroupUnreadChannels"] = *c.ServiceSettings.ExperimentalGroupUnreadChannels
props["EnableSVGs"] = strconv.FormatBool(*c.ServiceSettings.EnableSVGs)
props["EnableMarketplace"] = strconv.FormatBool(*c.PluginSettings.EnableMarketplace)
props["EnableLatex"] = strconv.FormatBool(*c.ServiceSettings.EnableLatex)
props["EnableInlineLatex"] = strconv.FormatBool(*c.ServiceSettings.EnableInlineLatex)
props["ExtendSessionLengthWithActivity"] = strconv.FormatBool(*c.ServiceSettings.ExtendSessionLengthWithActivity)
props["ManagedResourcePaths"] = *c.ServiceSettings.ManagedResourcePaths
// This setting is only temporary, so keep using the old setting name for the mobile and web apps
props["ExperimentalEnablePostMetadata"] = "true"
props["EnableAppBar"] = strconv.FormatBool(*c.ExperimentalSettings.EnableAppBar)
props["ExperimentalEnableAutomaticReplies"] = strconv.FormatBool(*c.TeamSettings.ExperimentalEnableAutomaticReplies)
props["ExperimentalTimezone"] = strconv.FormatBool(*c.DisplaySettings.ExperimentalTimezone)
props["SendEmailNotifications"] = strconv.FormatBool(*c.EmailSettings.SendEmailNotifications)
props["SendPushNotifications"] = strconv.FormatBool(*c.EmailSettings.SendPushNotifications)
props["RequireEmailVerification"] = strconv.FormatBool(*c.EmailSettings.RequireEmailVerification)
props["EnableEmailBatching"] = strconv.FormatBool(*c.EmailSettings.EnableEmailBatching)
props["EnablePreviewModeBanner"] = strconv.FormatBool(*c.EmailSettings.EnablePreviewModeBanner)
props["EmailNotificationContentsType"] = *c.EmailSettings.EmailNotificationContentsType
props["ShowEmailAddress"] = strconv.FormatBool(*c.PrivacySettings.ShowEmailAddress)
props["ShowFullName"] = strconv.FormatBool(*c.PrivacySettings.ShowFullName)
props["EnableFileAttachments"] = strconv.FormatBool(*c.FileSettings.EnableFileAttachments)
props["EnablePublicLink"] = strconv.FormatBool(*c.FileSettings.EnablePublicLink)
props["AvailableLocales"] = *c.LocalizationSettings.AvailableLocales
props["SQLDriverName"] = *c.SqlSettings.DriverName
props["EnableEmojiPicker"] = strconv.FormatBool(*c.ServiceSettings.EnableEmojiPicker)
props["EnableGifPicker"] = strconv.FormatBool(*c.ServiceSettings.EnableGifPicker)
props["GfycatApiKey"] = *c.ServiceSettings.GfycatAPIKey
props["GfycatApiSecret"] = *c.ServiceSettings.GfycatAPISecret
props["MaxFileSize"] = strconv.FormatInt(*c.FileSettings.MaxFileSize, 10)
props["MaxNotificationsPerChannel"] = strconv.FormatInt(*c.TeamSettings.MaxNotificationsPerChannel, 10)
props["EnableConfirmNotificationsToChannel"] = strconv.FormatBool(*c.TeamSettings.EnableConfirmNotificationsToChannel)
props["TimeBetweenUserTypingUpdatesMilliseconds"] = strconv.FormatInt(*c.ServiceSettings.TimeBetweenUserTypingUpdatesMilliseconds, 10)
props["EnableUserTypingMessages"] = strconv.FormatBool(*c.ServiceSettings.EnableUserTypingMessages)
props["EnableChannelViewedMessages"] = strconv.FormatBool(*c.ServiceSettings.EnableChannelViewedMessages)
props["RunJobs"] = strconv.FormatBool(*c.JobSettings.RunJobs)
props["EnableEmailInvitations"] = strconv.FormatBool(*c.ServiceSettings.EnableEmailInvitations)
props["CWSURL"] = *c.CloudSettings.CWSURL
// Set default values for all options that require a license.
props["ExperimentalEnableAuthenticationTransfer"] = "true"
props["LdapNicknameAttributeSet"] = "false"
props["LdapFirstNameAttributeSet"] = "false"
props["LdapLastNameAttributeSet"] = "false"
props["LdapPictureAttributeSet"] = "false"
props["LdapPositionAttributeSet"] = "false"
props["EnableCompliance"] = "false"
props["EnableMobileFileDownload"] = "true"
props["EnableMobileFileUpload"] = "true"
props["SamlFirstNameAttributeSet"] = "false"
props["SamlLastNameAttributeSet"] = "false"
props["SamlNicknameAttributeSet"] = "false"
props["SamlPositionAttributeSet"] = "false"
props["EnableCluster"] = "false"
props["EnableMetrics"] = "false"
props["EnableBanner"] = "false"
props["BannerText"] = ""
props["BannerColor"] = ""
props["BannerTextColor"] = ""
props["AllowBannerDismissal"] = "false"
props["EnableThemeSelection"] = "true"
props["DefaultTheme"] = ""
props["AllowCustomThemes"] = "true"
props["AllowedThemes"] = ""
props["DataRetentionEnableMessageDeletion"] = "false"
props["DataRetentionMessageRetentionDays"] = "0"
props["DataRetentionEnableFileDeletion"] = "false"
props["DataRetentionFileRetentionDays"] = "0"
props["DataRetentionEnableBoardsDeletion"] = "false"
props["DataRetentionBoardsRetentionDays"] = "0"
props["CustomUrlSchemes"] = strings.Join(c.DisplaySettings.CustomURLSchemes, ",")
props["IsDefaultMarketplace"] = strconv.FormatBool(*c.PluginSettings.MarketplaceURL == model.PluginSettingsDefaultMarketplaceURL)
props["ExperimentalSharedChannels"] = "false"
props["CollapsedThreads"] = *c.ServiceSettings.CollapsedThreads
props["EnableCustomGroups"] = "false"
props["InsightsEnabled"] = strconv.FormatBool(c.FeatureFlags.InsightsEnabled)
props["PostPriority"] = strconv.FormatBool(*c.ServiceSettings.PostPriority)
props["AllowSyncedDrafts"] = strconv.FormatBool(*c.ServiceSettings.AllowSyncedDrafts)
if license != nil {
props["ExperimentalEnableAuthenticationTransfer"] = strconv.FormatBool(*c.ServiceSettings.ExperimentalEnableAuthenticationTransfer)
if *license.Features.LDAP {
props["LdapNicknameAttributeSet"] = strconv.FormatBool(*c.LdapSettings.NicknameAttribute != "")
props["LdapFirstNameAttributeSet"] = strconv.FormatBool(*c.LdapSettings.FirstNameAttribute != "")
props["LdapLastNameAttributeSet"] = strconv.FormatBool(*c.LdapSettings.LastNameAttribute != "")
props["LdapPictureAttributeSet"] = strconv.FormatBool(*c.LdapSettings.PictureAttribute != "")
props["LdapPositionAttributeSet"] = strconv.FormatBool(*c.LdapSettings.PositionAttribute != "")
}
if *license.Features.Compliance {
props["EnableCompliance"] = strconv.FormatBool(*c.ComplianceSettings.Enable)
props["EnableMobileFileDownload"] = strconv.FormatBool(*c.FileSettings.EnableMobileDownload)
props["EnableMobileFileUpload"] = strconv.FormatBool(*c.FileSettings.EnableMobileUpload)
}
if *license.Features.SAML {
props["SamlFirstNameAttributeSet"] = strconv.FormatBool(*c.SamlSettings.FirstNameAttribute != "")
props["SamlLastNameAttributeSet"] = strconv.FormatBool(*c.SamlSettings.LastNameAttribute != "")
props["SamlNicknameAttributeSet"] = strconv.FormatBool(*c.SamlSettings.NicknameAttribute != "")
props["SamlPositionAttributeSet"] = strconv.FormatBool(*c.SamlSettings.PositionAttribute != "")
}
if *license.Features.FutureFeatures {
props["ExperimentalClientSideCertEnable"] = strconv.FormatBool(*c.ExperimentalSettings.ClientSideCertEnable)
props["ExperimentalClientSideCertCheck"] = *c.ExperimentalSettings.ClientSideCertCheck
}
if *license.Features.Cluster {
props["EnableCluster"] = strconv.FormatBool(*c.ClusterSettings.Enable)
}
if *license.Features.Cluster {
props["EnableMetrics"] = strconv.FormatBool(*c.MetricsSettings.Enable)
}
if *license.Features.Announcement {
props["EnableBanner"] = strconv.FormatBool(*c.AnnouncementSettings.EnableBanner)
props["BannerText"] = *c.AnnouncementSettings.BannerText
props["BannerColor"] = *c.AnnouncementSettings.BannerColor
props["BannerTextColor"] = *c.AnnouncementSettings.BannerTextColor
props["AllowBannerDismissal"] = strconv.FormatBool(*c.AnnouncementSettings.AllowBannerDismissal)
}
if *license.Features.ThemeManagement {
props["EnableThemeSelection"] = strconv.FormatBool(*c.ThemeSettings.EnableThemeSelection)
props["DefaultTheme"] = *c.ThemeSettings.DefaultTheme
props["AllowCustomThemes"] = strconv.FormatBool(*c.ThemeSettings.AllowCustomThemes)
props["AllowedThemes"] = strings.Join(c.ThemeSettings.AllowedThemes, ",")
}
if *license.Features.DataRetention {
props["DataRetentionEnableMessageDeletion"] = strconv.FormatBool(*c.DataRetentionSettings.EnableMessageDeletion)
props["DataRetentionMessageRetentionDays"] = strconv.FormatInt(int64(*c.DataRetentionSettings.MessageRetentionDays), 10)
props["DataRetentionEnableFileDeletion"] = strconv.FormatBool(*c.DataRetentionSettings.EnableFileDeletion)
props["DataRetentionFileRetentionDays"] = strconv.FormatInt(int64(*c.DataRetentionSettings.FileRetentionDays), 10)
props["DataRetentionEnableBoardsDeletion"] = strconv.FormatBool(*c.DataRetentionSettings.EnableBoardsDeletion)
props["DataRetentionBoardsRetentionDays"] = strconv.FormatInt(int64(*c.DataRetentionSettings.BoardsRetentionDays), 10)
}
if license.HasSharedChannels() {
props["ExperimentalSharedChannels"] = strconv.FormatBool(*c.ExperimentalSettings.EnableSharedChannels)
props["ExperimentalRemoteClusterService"] = strconv.FormatBool(c.FeatureFlags.EnableRemoteClusterService && *c.ExperimentalSettings.EnableRemoteClusterService)
}
if license.SkuShortName == model.LicenseShortSkuProfessional || license.SkuShortName == model.LicenseShortSkuEnterprise {
props["EnableCustomGroups"] = strconv.FormatBool(*c.ServiceSettings.EnableCustomGroups)
}
if (license.SkuShortName == model.LicenseShortSkuProfessional || license.SkuShortName == model.LicenseShortSkuEnterprise) && c.FeatureFlags.PostPriority {
props["PostAcknowledgements"] = "true"
}
}
return props
}
// GenerateLimitedClientConfig renders the given configuration for an untrusted client.
func GenerateLimitedClientConfig(c *model.Config, telemetryID string, license *model.License) map[string]string {
props := make(map[string]string)
props["Version"] = model.CurrentVersion
props["BuildNumber"] = model.BuildNumber
props["BuildDate"] = model.BuildDate
props["BuildHash"] = model.BuildHash
props["BuildHashEnterprise"] = model.BuildHashEnterprise
props["BuildEnterpriseReady"] = model.BuildEnterpriseReady
props["BuildHashBoards"] = model.BuildHashBoards
props["BuildBoards"] = model.BuildBoards
props["BuildHashPlaybooks"] = model.BuildHashPlaybooks
props["EnableBotAccountCreation"] = strconv.FormatBool(*c.ServiceSettings.EnableBotAccountCreation)
props["EnableFile"] = strconv.FormatBool(*c.LogSettings.EnableFile)
props["FileLevel"] = *c.LogSettings.FileLevel
props["SiteURL"] = strings.TrimRight(*c.ServiceSettings.SiteURL, "/")
props["SiteName"] = *c.TeamSettings.SiteName
props["WebsocketURL"] = strings.TrimRight(*c.ServiceSettings.WebsocketURL, "/")
props["WebsocketPort"] = fmt.Sprintf("%v", *c.ServiceSettings.WebsocketPort)
props["WebsocketSecurePort"] = fmt.Sprintf("%v", *c.ServiceSettings.WebsocketSecurePort)
props["EnableUserCreation"] = strconv.FormatBool(*c.TeamSettings.EnableUserCreation)
props["EnableOpenServer"] = strconv.FormatBool(*c.TeamSettings.EnableOpenServer)
props["AndroidLatestVersion"] = c.ClientRequirements.AndroidLatestVersion
props["AndroidMinVersion"] = c.ClientRequirements.AndroidMinVersion
props["IosLatestVersion"] = c.ClientRequirements.IosLatestVersion
props["IosMinVersion"] = c.ClientRequirements.IosMinVersion
props["EnableDiagnostics"] = strconv.FormatBool(*c.LogSettings.EnableDiagnostics)
props["EnableComplianceExport"] = strconv.FormatBool(*c.MessageExportSettings.EnableExport)
props["EnableSignUpWithEmail"] = strconv.FormatBool(*c.EmailSettings.EnableSignUpWithEmail)
props["EnableSignInWithEmail"] = strconv.FormatBool(*c.EmailSettings.EnableSignInWithEmail)
props["EnableSignInWithUsername"] = strconv.FormatBool(*c.EmailSettings.EnableSignInWithUsername)
props["EmailLoginButtonColor"] = *c.EmailSettings.LoginButtonColor
props["EmailLoginButtonBorderColor"] = *c.EmailSettings.LoginButtonBorderColor
props["EmailLoginButtonTextColor"] = *c.EmailSettings.LoginButtonTextColor
props["EnableSignUpWithGitLab"] = strconv.FormatBool(*c.GitLabSettings.Enable)
props["GitLabButtonColor"] = *c.GitLabSettings.ButtonColor
props["GitLabButtonText"] = *c.GitLabSettings.ButtonText
props["TermsOfServiceLink"] = *c.SupportSettings.TermsOfServiceLink
props["PrivacyPolicyLink"] = *c.SupportSettings.PrivacyPolicyLink
props["AboutLink"] = *c.SupportSettings.AboutLink
props["HelpLink"] = *c.SupportSettings.HelpLink
props["ReportAProblemLink"] = *c.SupportSettings.ReportAProblemLink
props["SupportEmail"] = *c.SupportSettings.SupportEmail
props["EnableAskCommunityLink"] = strconv.FormatBool(*c.SupportSettings.EnableAskCommunityLink)
props["DefaultClientLocale"] = *c.LocalizationSettings.DefaultClientLocale
props["EnableCustomEmoji"] = strconv.FormatBool(*c.ServiceSettings.EnableCustomEmoji)
props["AppDownloadLink"] = *c.NativeAppSettings.AppDownloadLink
props["AndroidAppDownloadLink"] = *c.NativeAppSettings.AndroidAppDownloadLink
props["IosAppDownloadLink"] = *c.NativeAppSettings.IosAppDownloadLink
props["DiagnosticId"] = telemetryID
props["TelemetryId"] = telemetryID
props["DiagnosticsEnabled"] = strconv.FormatBool(*c.LogSettings.EnableDiagnostics)
props["HasImageProxy"] = strconv.FormatBool(*c.ImageProxySettings.Enable)
props["PluginsEnabled"] = strconv.FormatBool(*c.PluginSettings.Enable)
props["PasswordMinimumLength"] = fmt.Sprintf("%v", *c.PasswordSettings.MinimumLength)
props["PasswordRequireLowercase"] = strconv.FormatBool(*c.PasswordSettings.Lowercase)
props["PasswordRequireUppercase"] = strconv.FormatBool(*c.PasswordSettings.Uppercase)
props["PasswordRequireNumber"] = strconv.FormatBool(*c.PasswordSettings.Number)
props["PasswordRequireSymbol"] = strconv.FormatBool(*c.PasswordSettings.Symbol)
// Set default values for all options that require a license.
props["EnableCustomBrand"] = "false"
props["CustomBrandText"] = ""
props["CustomDescriptionText"] = ""
props["EnableLdap"] = "false"
props["LdapLoginFieldName"] = ""
props["LdapLoginButtonColor"] = ""
props["LdapLoginButtonBorderColor"] = ""
props["LdapLoginButtonTextColor"] = ""
props["EnableSaml"] = "false"
props["SamlLoginButtonText"] = ""
props["SamlLoginButtonColor"] = ""
props["SamlLoginButtonBorderColor"] = ""
props["SamlLoginButtonTextColor"] = ""
props["EnableSignUpWithGoogle"] = "false"
props["EnableSignUpWithOffice365"] = "false"
props["EnableSignUpWithOpenId"] = "false"
props["OpenIdButtonText"] = ""
props["OpenIdButtonColor"] = ""
props["CWSURL"] = ""
props["EnableCustomBrand"] = strconv.FormatBool(*c.TeamSettings.EnableCustomBrand)
props["CustomBrandText"] = *c.TeamSettings.CustomBrandText
props["CustomDescriptionText"] = *c.TeamSettings.CustomDescriptionText
props["EnableMultifactorAuthentication"] = strconv.FormatBool(*c.ServiceSettings.EnableMultifactorAuthentication)
props["EnforceMultifactorAuthentication"] = "false"
props["EnableGuestAccounts"] = strconv.FormatBool(*c.GuestAccountsSettings.Enable)
props["GuestAccountsEnforceMultifactorAuthentication"] = strconv.FormatBool(*c.GuestAccountsSettings.EnforceMultifactorAuthentication)
if license != nil {
if *license.Features.LDAP {
props["EnableLdap"] = strconv.FormatBool(*c.LdapSettings.Enable)
props["LdapLoginFieldName"] = *c.LdapSettings.LoginFieldName
props["LdapLoginButtonColor"] = *c.LdapSettings.LoginButtonColor
props["LdapLoginButtonBorderColor"] = *c.LdapSettings.LoginButtonBorderColor
props["LdapLoginButtonTextColor"] = *c.LdapSettings.LoginButtonTextColor
}
if *license.Features.SAML {
props["EnableSaml"] = strconv.FormatBool(*c.SamlSettings.Enable)
props["SamlLoginButtonText"] = *c.SamlSettings.LoginButtonText
props["SamlLoginButtonColor"] = *c.SamlSettings.LoginButtonColor
props["SamlLoginButtonBorderColor"] = *c.SamlSettings.LoginButtonBorderColor
props["SamlLoginButtonTextColor"] = *c.SamlSettings.LoginButtonTextColor
}
if *license.Features.CustomTermsOfService {
props["EnableCustomTermsOfService"] = strconv.FormatBool(*c.SupportSettings.CustomTermsOfServiceEnabled)
props["CustomTermsOfServiceReAcceptancePeriod"] = strconv.FormatInt(int64(*c.SupportSettings.CustomTermsOfServiceReAcceptancePeriod), 10)
}
if *license.Features.MFA {
props["EnforceMultifactorAuthentication"] = strconv.FormatBool(*c.ServiceSettings.EnforceMultifactorAuthentication)
}
if license.IsCloud() {
// MM-48727: enable SSO options for free cloud - not in self hosted
*license.Features.GoogleOAuth = true
*license.Features.Office365OAuth = true
}
if *license.Features.GoogleOAuth {
props["EnableSignUpWithGoogle"] = strconv.FormatBool(*c.GoogleSettings.Enable)
}
if *license.Features.Office365OAuth {
props["EnableSignUpWithOffice365"] = strconv.FormatBool(*c.Office365Settings.Enable)
}
if *license.Features.OpenId {
props["EnableSignUpWithOpenId"] = strconv.FormatBool(*c.OpenIdSettings.Enable)
props["OpenIdButtonColor"] = *c.OpenIdSettings.ButtonColor
props["OpenIdButtonText"] = *c.OpenIdSettings.ButtonText
}
}
for key, value := range c.FeatureFlags.ToMap() {
props["FeatureFlag"+key] = value
}
return props
}

442
server/config/client_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,442 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package config
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost-server/v6/model"
)
func TestGetClientConfig(t *testing.T) {
t.Parallel()
testCases := []struct {
description string
config *model.Config
telemetryID string
license *model.License
expectedFields map[string]string
}{
{
"unlicensed",
&model.Config{
EmailSettings: model.EmailSettings{
EmailNotificationContentsType: model.NewString(model.EmailNotificationContentsFull),
},
ThemeSettings: model.ThemeSettings{
// Ignored, since not licensed.
AllowCustomThemes: model.NewBool(false),
},
ServiceSettings: model.ServiceSettings{
WebsocketURL: model.NewString("ws://mattermost.example.com:8065"),
WebsocketPort: model.NewInt(80),
WebsocketSecurePort: model.NewInt(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: model.NewString(model.EmailNotificationContentsFull),
},
ThemeSettings: model.ThemeSettings{
// Ignored, since not licensed.
AllowCustomThemes: model.NewBool(false),
},
},
"tag1",
&model.License{
Features: &model.Features{
ThemeManagement: model.NewBool(false),
},
},
map[string]string{
"DiagnosticId": "tag1",
"EmailNotificationContentsType": "full",
"AllowCustomThemes": "true",
},
},
{
"licensed for theme management",
&model.Config{
EmailSettings: model.EmailSettings{
EmailNotificationContentsType: model.NewString(model.EmailNotificationContentsFull),
},
ThemeSettings: model.ThemeSettings{
AllowCustomThemes: model.NewBool(false),
},
},
"tag2",
&model.License{
Features: &model.Features{
ThemeManagement: model.NewBool(true),
},
},
map[string]string{
"DiagnosticId": "tag2",
"EmailNotificationContentsType": "full",
"AllowCustomThemes": "false",
},
},
{
"licensed for enforcement",
&model.Config{
ServiceSettings: model.ServiceSettings{
EnforceMultifactorAuthentication: model.NewBool(true),
},
},
"tag1",
&model.License{
Features: &model.Features{
MFA: model.NewBool(true),
},
},
map[string]string{
"EnforceMultifactorAuthentication": "true",
},
},
{
"default marketplace",
&model.Config{
PluginSettings: model.PluginSettings{
MarketplaceURL: model.NewString(model.PluginSettingsDefaultMarketplaceURL),
},
},
"tag1",
nil,
map[string]string{
"IsDefaultMarketplace": "true",
},
},
{
"non-default marketplace",
&model.Config{
PluginSettings: model.PluginSettings{
MarketplaceURL: model.NewString("http://example.com"),
},
},
"tag1",
nil,
map[string]string{
"IsDefaultMarketplace": "false",
},
},
{
"enable ShowFullName prop",
&model.Config{
PrivacySettings: model.PrivacySettings{
ShowFullName: model.NewBool(true),
},
},
"tag1",
nil,
map[string]string{
"ShowFullName": "true",
},
},
{
"Insights professional license",
&model.Config{
FeatureFlags: &model.FeatureFlags{
InsightsEnabled: true,
},
},
"",
&model.License{
Features: &model.Features{},
SkuShortName: model.LicenseShortSkuProfessional,
},
map[string]string{
"InsightsEnabled": "true",
},
},
{
"Insights enterprise license",
&model.Config{
FeatureFlags: &model.FeatureFlags{
InsightsEnabled: true,
},
},
"",
&model.License{
Features: &model.Features{},
SkuShortName: model.LicenseShortSkuEnterprise,
},
map[string]string{
"InsightsEnabled": "true",
},
},
{
"Insights other license",
&model.Config{
FeatureFlags: &model.FeatureFlags{
InsightsEnabled: true,
},
},
"",
&model.License{
Features: &model.Features{},
SkuShortName: "other",
},
map[string]string{
"InsightsEnabled": "true",
},
},
{
"Insights professional license, feature flag disabled",
&model.Config{
FeatureFlags: &model.FeatureFlags{
InsightsEnabled: false,
},
},
"",
&model.License{
Features: &model.Features{},
SkuShortName: model.LicenseShortSkuProfessional,
},
map[string]string{
"InsightsEnabled": "false",
},
},
{
"Custom groups professional license",
&model.Config{},
"",
&model.License{
Features: &model.Features{},
SkuShortName: model.LicenseShortSkuProfessional,
},
map[string]string{
"EnableCustomGroups": "true",
},
},
{
"Custom groups enterprise license",
&model.Config{},
"",
&model.License{
Features: &model.Features{},
SkuShortName: model.LicenseShortSkuEnterprise,
},
map[string]string{
"EnableCustomGroups": "true",
},
},
{
"Custom groups other license",
&model.Config{
FeatureFlags: &model.FeatureFlags{
InsightsEnabled: true,
},
},
"",
&model.License{
Features: &model.Features{},
SkuShortName: "other",
},
map[string]string{
"EnableCustomGroups": "false",
},
},
{
"Shared channels other license",
&model.Config{
ExperimentalSettings: model.ExperimentalSettings{
EnableSharedChannels: model.NewBool(true),
},
},
"",
&model.License{
Features: &model.Features{
SharedChannels: model.NewBool(false),
},
SkuShortName: "other",
},
map[string]string{
"ExperimentalSharedChannels": "false",
},
},
{
"licensed for shared channels",
&model.Config{
ExperimentalSettings: model.ExperimentalSettings{
EnableSharedChannels: model.NewBool(true),
},
},
"",
&model.License{
Features: &model.Features{
SharedChannels: model.NewBool(true),
},
SkuShortName: "other",
},
map[string]string{
"ExperimentalSharedChannels": "true",
},
},
{
"Shared channels professional license",
&model.Config{
ExperimentalSettings: model.ExperimentalSettings{
EnableSharedChannels: model.NewBool(true),
},
},
"",
&model.License{
Features: &model.Features{
SharedChannels: model.NewBool(false),
},
SkuShortName: model.LicenseShortSkuProfessional,
},
map[string]string{
"ExperimentalSharedChannels": "true",
},
},
{
"Shared channels enterprise license",
&model.Config{
ExperimentalSettings: model.ExperimentalSettings{
EnableSharedChannels: model.NewBool(true),
},
},
"",
&model.License{
Features: &model.Features{
SharedChannels: model.NewBool(false),
},
SkuShortName: model.LicenseShortSkuEnterprise,
},
map[string]string{
"ExperimentalSharedChannels": "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 := GenerateClientConfig(testCase.config, testCase.telemetryID, 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
telemetryID string
license *model.License
expectedFields map[string]string
}{
{
"unlicensed",
&model.Config{
EmailSettings: model.EmailSettings{
EmailNotificationContentsType: model.NewString(model.EmailNotificationContentsFull),
},
ThemeSettings: model.ThemeSettings{
// Ignored, since not licensed.
AllowCustomThemes: model.NewBool(false),
},
ServiceSettings: model.ServiceSettings{
WebsocketURL: model.NewString("ws://mattermost.example.com:8065"),
WebsocketPort: model.NewInt(80),
WebsocketSecurePort: model.NewInt(443),
},
},
"",
nil,
map[string]string{
"DiagnosticId": "",
"EnforceMultifactorAuthentication": "false",
"WebsocketURL": "ws://mattermost.example.com:8065",
"WebsocketPort": "80",
"WebsocketSecurePort": "443",
},
},
{
"password settings",
&model.Config{
PasswordSettings: model.PasswordSettings{
MinimumLength: model.NewInt(15),
Lowercase: model.NewBool(true),
Uppercase: model.NewBool(true),
Number: model.NewBool(true),
Symbol: model.NewBool(false),
},
},
"",
nil,
map[string]string{
"PasswordMinimumLength": "15",
"PasswordRequireLowercase": "true",
"PasswordRequireUppercase": "true",
"PasswordRequireNumber": "true",
"PasswordRequireSymbol": "false",
},
},
{
"Feature Flags",
&model.Config{
FeatureFlags: &model.FeatureFlags{
TestFeature: "myvalue",
},
},
"",
nil,
map[string]string{
"FeatureFlagTestFeature": "myvalue",
},
},
}
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.telemetryID, 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)
}
}
})
}
}

182
server/config/common_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,182 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package config
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost-server/v6/model"
)
var emptyConfig, readOnlyConfig, minimalConfig, minimalConfigNoFF, invalidConfig, fixesRequiredConfig, ldapConfig, testConfig, customConfigDefaults *model.Config
func init() {
emptyConfig = &model.Config{}
readOnlyConfig = &model.Config{
ClusterSettings: model.ClusterSettings{
Enable: model.NewBool(true),
ReadOnlyConfig: model.NewBool(true),
},
}
minimalConfig = &model.Config{
ServiceSettings: model.ServiceSettings{
SiteURL: model.NewString("http://minimal"),
},
SqlSettings: model.SqlSettings{
AtRestEncryptKey: model.NewString("abcdefghijklmnopqrstuvwxyz0123456789"),
},
FileSettings: model.FileSettings{
PublicLinkSalt: model.NewString("abcdefghijklmnopqrstuvwxyz0123456789"),
},
LocalizationSettings: model.LocalizationSettings{
DefaultServerLocale: model.NewString("en"),
DefaultClientLocale: model.NewString("en"),
},
}
minimalConfig.SetDefaults()
minimalConfigNoFF = minimalConfig.Clone()
minimalConfigNoFF.FeatureFlags = nil
invalidConfig = &model.Config{
ServiceSettings: model.ServiceSettings{
SiteURL: model.NewString("invalid"),
},
}
fixesRequiredConfig = &model.Config{
ServiceSettings: model.ServiceSettings{
SiteURL: model.NewString("http://trailingslash/"),
},
SqlSettings: model.SqlSettings{
AtRestEncryptKey: model.NewString("abcdefghijklmnopqrstuvwxyz0123456789"),
},
FileSettings: model.FileSettings{
DriverName: model.NewString(model.ImageDriverLocal),
Directory: model.NewString("/path/to/directory"),
PublicLinkSalt: model.NewString("abcdefghijklmnopqrstuvwxyz0123456789"),
},
LocalizationSettings: model.LocalizationSettings{
DefaultServerLocale: model.NewString("garbage"),
DefaultClientLocale: model.NewString("garbage"),
},
}
ldapConfig = &model.Config{
LdapSettings: model.LdapSettings{
BindPassword: model.NewString("password"),
},
}
testConfig = &model.Config{
ServiceSettings: model.ServiceSettings{
SiteURL: model.NewString("http://TestStoreNew"),
},
}
customConfigDefaults = &model.Config{
ServiceSettings: model.ServiceSettings{
SiteURL: model.NewString("http://custom.com"),
},
DisplaySettings: model.DisplaySettings{
ExperimentalTimezone: model.NewBool(false),
},
}
}
func TestMergeConfigs(t *testing.T) {
t.Run("merge two default configs with different salts/keys", func(t *testing.T) {
base := &model.Config{}
base.SetDefaults()
patch := &model.Config{}
patch.SetDefaults()
merged, err := Merge(base, patch, nil)
require.NoError(t, err)
assert.Equal(t, patch, merged)
})
t.Run("merge identical configs", func(t *testing.T) {
base := &model.Config{}
base.SetDefaults()
patch := base.Clone()
merged, err := Merge(base, patch, nil)
require.NoError(t, err)
assert.Equal(t, base, merged)
assert.Equal(t, patch, merged)
})
t.Run("merge configs with a different setting", func(t *testing.T) {
base := &model.Config{}
base.SetDefaults()
patch := base.Clone()
patch.ServiceSettings.SiteURL = model.NewString("http://newhost.ca")
merged, err := Merge(base, patch, nil)
require.NoError(t, err)
assert.NotEqual(t, base, merged)
assert.Equal(t, patch, merged)
})
t.Run("merge default config with changes from a mostly nil patch", func(t *testing.T) {
base := &model.Config{}
base.SetDefaults()
patch := &model.Config{}
patch.ServiceSettings.SiteURL = model.NewString("http://newhost.ca")
patch.GoogleSettings.Enable = model.NewBool(true)
expected := base.Clone()
expected.ServiceSettings.SiteURL = model.NewString("http://newhost.ca")
expected.GoogleSettings.Enable = model.NewBool(true)
merged, err := Merge(base, patch, nil)
require.NoError(t, err)
assert.NotEqual(t, base, merged)
assert.NotEqual(t, patch, merged)
assert.Equal(t, expected, merged)
})
}
func TestConfigEnvironmentOverrides(t *testing.T) {
memstore, err := NewMemoryStore()
require.NoError(t, err)
base, err := NewStoreFromBacking(memstore, nil, false)
require.NoError(t, err)
originalConfig := &model.Config{}
originalConfig.ServiceSettings.SiteURL = model.NewString("http://notoverridden.ca")
os.Setenv("MM_SERVICESETTINGS_SITEURL", "http://overridden.ca")
defer os.Unsetenv("MM_SERVICESETTINGS_SITEURL")
t.Run("loading config should respect environment variable overrides", func(t *testing.T) {
err := base.Load()
require.NoError(t, err)
assert.Equal(t, "http://overridden.ca", *base.Get().ServiceSettings.SiteURL)
})
t.Run("setting config should respect environment variable overrides", func(t *testing.T) {
_, _, err := base.Set(originalConfig)
require.NoError(t, err)
assert.Equal(t, "http://overridden.ca", *base.Get().ServiceSettings.SiteURL)
})
}
func TestRemoveEnvironmentOverrides(t *testing.T) {
os.Setenv("MM_SERVICESETTINGS_SITEURL", "http://overridden.ca")
defer os.Unsetenv("MM_SERVICESETTINGS_SITEURL")
memstore, err := NewMemoryStore()
require.NoError(t, err)
base, err := NewStoreFromBacking(memstore, nil, false)
require.NoError(t, err)
oldCfg := base.Get()
assert.Equal(t, "http://overridden.ca", *oldCfg.ServiceSettings.SiteURL)
newCfg := base.RemoveEnvironmentOverrides(oldCfg)
assert.Equal(t, "", *newCfg.ServiceSettings.SiteURL)
}

425
server/config/database.go Обычный файл
Просмотреть файл

@@ -0,0 +1,425 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package config
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"embed"
"encoding/hex"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"github.com/jmoiron/sqlx"
"github.com/pkg/errors"
// Load the MySQL driver
_ "github.com/go-sql-driver/mysql"
// Load the Postgres driver
_ "github.com/lib/pq"
"github.com/mattermost/morph"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/channels/store/sqlstore"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
"github.com/mattermost/morph/drivers"
ms "github.com/mattermost/morph/drivers/mysql"
ps "github.com/mattermost/morph/drivers/postgres"
mbindata "github.com/mattermost/morph/sources/embedded"
)
//go:embed migrations
var assets embed.FS
// MaxWriteLength defines the maximum length accepted for write to the Configurations or
// ConfigurationFiles table.
//
// It is imposed by MySQL's default max_allowed_packet value of 4Mb.
const MaxWriteLength = 4 * 1024 * 1024
// We use the something different from the default migration table name of morph
const migrationsTableName = "db_config_migrations"
// The timeout value for each migration file to run.
const migrationsTimeoutInSeconds = 100000
// DatabaseStore is a config store backed by a database.
// Not to be used directly. Only to be used as a backing store for config.Store
type DatabaseStore struct {
originalDsn string
driverName string
dataSourceName string
db *sqlx.DB
}
// NewDatabaseStore creates a new instance of a config store backed by the given database.
func NewDatabaseStore(dsn string) (ds *DatabaseStore, err error) {
driverName, dataSourceName, err := parseDSN(dsn)
if err != nil {
return nil, errors.Wrap(err, "invalid DSN")
}
db, err := sqlx.Open(driverName, dataSourceName)
if err != nil {
return nil, errors.Wrapf(err, "failed to connect to %s database", driverName)
}
// Set conservative connection configuration for configuration database.
db.SetMaxIdleConns(0)
db.SetMaxOpenConns(2)
defer func() {
if err != nil {
db.Close()
}
}()
ds = &DatabaseStore{
driverName: driverName,
originalDsn: dsn,
dataSourceName: dataSourceName,
db: db,
}
if err = ds.initializeConfigurationsTable(); err != nil {
err = errors.Wrap(err, "failed to initialize")
return nil, err
}
return ds, nil
}
// initializeConfigurationsTable ensures the requisite tables in place to form the backing store.
//
// Uses MEDIUMTEXT on MySQL, and TEXT on sane databases.
func (ds *DatabaseStore) initializeConfigurationsTable() error {
assetsList, err := assets.ReadDir(filepath.Join("migrations", ds.driverName))
if err != nil {
return err
}
assetNamesForDriver := make([]string, len(assetsList))
for i, entry := range assetsList {
assetNamesForDriver[i] = entry.Name()
}
src, err := mbindata.WithInstance(&mbindata.AssetSource{
Names: assetNamesForDriver,
AssetFunc: func(name string) ([]byte, error) {
return assets.ReadFile(filepath.Join("migrations", ds.driverName, name))
},
})
if err != nil {
return err
}
var driver drivers.Driver
switch ds.driverName {
case model.DatabaseDriverMysql:
dataSource, rErr := sqlstore.ResetReadTimeout(ds.dataSourceName)
if rErr != nil {
return fmt.Errorf("failed to reset read timeout from datasource: %w", rErr)
}
dataSource, err = sqlstore.AppendMultipleStatementsFlag(dataSource)
if err != nil {
return err
}
var db *sqlx.DB
db, err = sqlx.Open(ds.driverName, dataSource)
if err != nil {
return errors.Wrapf(err, "failed to connect to %s database", ds.driverName)
}
driver, err = ms.WithInstance(db.DB)
defer db.Close()
case model.DatabaseDriverPostgres:
driver, err = ps.WithInstance(ds.db.DB)
default:
err = fmt.Errorf("unsupported database type %s for migration", ds.driverName)
}
if err != nil {
return err
}
opts := []morph.EngineOption{
morph.WithLock("mm-config-lock-key"),
morph.SetMigrationTableName(migrationsTableName),
morph.SetStatementTimeoutInSeconds(migrationsTimeoutInSeconds),
}
engine, err := morph.New(context.Background(), driver, src, opts...)
if err != nil {
return err
}
defer engine.Close()
return engine.ApplyAll()
}
// parseDSN splits up a connection string into a driver name and data source name.
//
// For example:
//
// mysql://mmuser:mostest@localhost:5432/mattermost_test
//
// returns
//
// driverName = mysql
// dataSourceName = mmuser:mostest@localhost:5432/mattermost_test
//
// By contrast, a Postgres DSN is returned unmodified.
func parseDSN(dsn string) (string, string, error) {
// Treat the DSN as the URL that it is.
s := strings.SplitN(dsn, "://", 2)
if len(s) != 2 {
return "", "", errors.New("failed to parse DSN as URL")
}
scheme := s[0]
switch scheme {
case "mysql":
// Strip off the mysql:// for the dsn with which to connect.
dsn = s[1]
case "postgres", "postgresql":
// No changes required
default:
return "", "", errors.Errorf("unsupported scheme %s", scheme)
}
return scheme, dsn, nil
}
// Set replaces the current configuration in its entirety and updates the backing store.
func (ds *DatabaseStore) Set(newCfg *model.Config) error {
return ds.persist(newCfg)
}
// maxLength identifies the maximum length of a configuration or configuration file
func (ds *DatabaseStore) checkLength(length int) error {
if ds.db.DriverName() == "mysql" && length > MaxWriteLength {
return errors.Errorf("value is too long: %d > %d bytes", length, MaxWriteLength)
}
return nil
}
// persist writes the configuration to the configured database.
func (ds *DatabaseStore) persist(cfg *model.Config) error {
b, err := marshalConfig(cfg)
if err != nil {
return errors.Wrap(err, "failed to serialize")
}
value := string(b)
err = ds.checkLength(len(value))
if err != nil {
return errors.Wrap(err, "marshalled configuration failed length check")
}
sum := sha256.Sum256(b)
// Skip the persist altogether if we're effectively writing the same configuration.
var oldValue string
var row *sql.Row
if ds.driverName == model.DatabaseDriverMysql {
// We use a sub-query to get the Id first because selecting the Id column using
// active uses the index, but selecting SHA column using active does not use the index.
// The sub-query uses the active index, and then the top-level query uses the primary key.
// This takes 2 queries, but it is actually faster than one slow query for MySQL
row = ds.db.QueryRow("SELECT SHA FROM Configurations WHERE Id = (select Id from Configurations Where Active)")
} else {
row = ds.db.QueryRow("SELECT SHA FROM Configurations WHERE Active")
}
if err = row.Scan(&oldValue); err != nil && err != sql.ErrNoRows {
return errors.Wrap(err, "failed to query active configuration")
}
// postgres retruns blank-padded therefore we trim the space
oldSum, err := hex.DecodeString(strings.TrimSpace(oldValue))
if err != nil {
return errors.Wrap(err, "could not encode value")
}
// compare checksums, it's more efficient rather than comparing entire config itself
if bytes.Equal(oldSum, sum[0:]) {
return nil
}
tx, err := ds.db.Beginx()
if err != nil {
return errors.Wrap(err, "failed to begin transaction")
}
defer func() {
// Rollback after Commit just returns sql.ErrTxDone.
if err = tx.Rollback(); err != nil && err != sql.ErrTxDone {
mlog.Error("Failed to rollback configuration transaction", mlog.Err(err))
}
}()
var oldId string
if ds.driverName == model.DatabaseDriverMysql {
// the query doesn't use active index if we query for value (mysql, no surprise)
// we select Id column which triggers using index hence we do quicker reads
// that's the reason we select id first then query against id to get the value.
row = tx.QueryRow("SELECT Id FROM Configurations WHERE Active")
if err = row.Scan(&oldId); err != nil && err != sql.ErrNoRows {
return errors.Wrap(err, "failed to query active configuration")
}
if oldId != "" {
if _, err := tx.NamedExec("UPDATE Configurations SET Active = NULL WHERE Id = :id", map[string]any{"id": oldId}); err != nil {
return errors.Wrap(err, "failed to deactivate current configuration")
}
}
} else {
if _, err := tx.Exec("UPDATE Configurations SET Active = NULL WHERE Active"); err != nil {
return errors.Wrap(err, "failed to deactivate current configuration")
}
}
params := map[string]any{
"id": model.NewId(),
"value": value,
"create_at": model.GetMillis(),
"key": "ConfigurationId",
"sha": hex.EncodeToString(sum[0:]),
}
if _, err := tx.NamedExec("INSERT INTO Configurations (Id, Value, CreateAt, Active, SHA) VALUES (:id, :value, :create_at, TRUE, :sha)", params); err != nil {
return errors.Wrap(err, "failed to record new configuration")
}
if err := tx.Commit(); err != nil {
return errors.Wrap(err, "failed to commit transaction")
}
return nil
}
// Load updates the current configuration from the backing store.
func (ds *DatabaseStore) Load() ([]byte, error) {
var configurationData []byte
row := ds.db.QueryRow("SELECT Value FROM Configurations WHERE Active")
if err := row.Scan(&configurationData); err != nil && err != sql.ErrNoRows {
return nil, errors.Wrap(err, "failed to query active configuration")
}
// Initialize from the default config if no active configuration could be found.
if len(configurationData) == 0 {
configWithDB := model.Config{}
configWithDB.SqlSettings.DriverName = model.NewString(ds.driverName)
configWithDB.SqlSettings.DataSource = model.NewString(ds.dataSourceName)
return json.Marshal(configWithDB)
}
return configurationData, nil
}
// GetFile fetches the contents of a previously persisted configuration file.
func (ds *DatabaseStore) GetFile(name string) ([]byte, error) {
query, args, err := sqlx.Named("SELECT Data FROM ConfigurationFiles WHERE Name = :name", map[string]any{
"name": name,
})
if err != nil {
return nil, err
}
var data []byte
row := ds.db.QueryRowx(ds.db.Rebind(query), args...)
if err = row.Scan(&data); err != nil {
return nil, errors.Wrapf(err, "failed to scan data from row for %s", name)
}
return data, nil
}
// SetFile sets or replaces the contents of a configuration file.
func (ds *DatabaseStore) SetFile(name string, data []byte) error {
err := ds.checkLength(len(data))
if err != nil {
return errors.Wrap(err, "file data failed length check")
}
params := map[string]any{
"name": name,
"data": data,
"create_at": model.GetMillis(),
"update_at": model.GetMillis(),
}
result, err := ds.db.NamedExec("UPDATE ConfigurationFiles SET Data = :data, UpdateAt = :update_at WHERE Name = :name", params)
if err != nil {
return errors.Wrapf(err, "failed to update row for %s", name)
}
count, err := result.RowsAffected()
if err != nil {
return errors.Wrapf(err, "failed to count rows affected for %s", name)
} else if count > 0 {
return nil
}
_, err = ds.db.NamedExec("INSERT INTO ConfigurationFiles (Name, Data, CreateAt, UpdateAt) VALUES (:name, :data, :create_at, :update_at)", params)
if err != nil {
return errors.Wrapf(err, "failed to insert row for %s", name)
}
return nil
}
// HasFile returns true if the given file was previously persisted.
func (ds *DatabaseStore) HasFile(name string) (bool, error) {
query, args, err := sqlx.Named("SELECT COUNT(*) FROM ConfigurationFiles WHERE Name = :name", map[string]any{
"name": name,
})
if err != nil {
return false, err
}
var count int64
row := ds.db.QueryRowx(ds.db.Rebind(query), args...)
if err = row.Scan(&count); err != nil {
return false, errors.Wrapf(err, "failed to scan count of rows for %s", name)
}
return count != 0, nil
}
// RemoveFile remoevs a previously persisted configuration file.
func (ds *DatabaseStore) RemoveFile(name string) error {
_, err := ds.db.NamedExec("DELETE FROM ConfigurationFiles WHERE Name = :name", map[string]any{
"name": name,
})
if err != nil {
return errors.Wrapf(err, "failed to remove row for %s", name)
}
return nil
}
// String returns the path to the database backing the config, masking the password.
func (ds *DatabaseStore) String() string {
return stripPassword(ds.originalDsn, ds.driverName)
}
// Close cleans up resources associated with the store.
func (ds *DatabaseStore) Close() error {
return ds.db.Close()
}
// removes configurations from database if they are older than threshold.
func (ds *DatabaseStore) cleanUp(thresholdCreatAt int) error {
if _, err := ds.db.NamedExec("DELETE FROM Configurations Where CreateAt < :timestamp", map[string]any{"timestamp": thresholdCreatAt}); err != nil {
return errors.Wrap(err, "unable to clean Configurations table")
}
return nil
}

1165
server/config/database_test.go Обычный файл

Разница между файлами не показана из-за своего большого размера Загрузить разницу

161
server/config/diff.go Обычный файл
Просмотреть файл

@@ -0,0 +1,161 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package config
import (
"fmt"
"reflect"
"github.com/mattermost/mattermost-server/v6/model"
)
type ConfigDiffs []ConfigDiff
type ConfigDiff struct {
Path string `json:"path"`
BaseVal any `json:"base_val"`
ActualVal any `json:"actual_val"`
}
func (c *ConfigDiff) Auditable() map[string]interface{} {
return map[string]interface{}{
"path": c.Path,
"base_val": c.BaseVal,
"actual_val": c.ActualVal,
}
}
func (cd *ConfigDiffs) Auditable() map[string]interface{} {
var s []interface{}
for _, d := range cd.Sanitize() {
s = append(s, d.Auditable())
}
return map[string]interface{}{
"config_diffs": s,
}
}
var configSensitivePaths = map[string]bool{
"LdapSettings.BindPassword": true,
"FileSettings.PublicLinkSalt": true,
"FileSettings.AmazonS3SecretAccessKey": true,
"SqlSettings.DataSource": true,
"SqlSettings.AtRestEncryptKey": true,
"SqlSettings.DataSourceReplicas": true,
"SqlSettings.DataSourceSearchReplicas": true,
"EmailSettings.SMTPPassword": true,
"GitLabSettings.Secret": true,
"GoogleSettings.Secret": true,
"Office365Settings.Secret": true,
"OpenIdSettings.Secret": true,
"ElasticsearchSettings.Password": true,
"MessageExportSettings.GlobalRelaySettings.SMTPUsername": true,
"MessageExportSettings.GlobalRelaySettings.SMTPPassword": true,
"MessageExportSettings.GlobalRelaySettings.EmailAddress": true,
"ServiceSettings.GfycatAPISecret": true,
"ServiceSettings.SplitKey": true,
"PluginSettings.Plugins": true,
}
// Sanitize replaces sensitive config values in the diff with asterisks filled strings.
func (cd ConfigDiffs) Sanitize() ConfigDiffs {
if len(cd) == 1 {
cfgPtr, ok := cd[0].BaseVal.(*model.Config)
if ok {
cfgPtr.Sanitize()
}
cfgPtr, ok = cd[0].ActualVal.(*model.Config)
if ok {
cfgPtr.Sanitize()
}
cfgVal, ok := cd[0].BaseVal.(model.Config)
if ok {
cfgVal.Sanitize()
}
cfgVal, ok = cd[0].ActualVal.(model.Config)
if ok {
cfgVal.Sanitize()
}
}
for i := range cd {
if configSensitivePaths[cd[i].Path] {
cd[i].BaseVal = model.FakeSetting
cd[i].ActualVal = model.FakeSetting
}
}
return cd
}
func diff(base, actual reflect.Value, label string) ([]ConfigDiff, error) {
var diffs []ConfigDiff
if base.IsZero() && actual.IsZero() {
return diffs, nil
}
if base.IsZero() || actual.IsZero() {
return append(diffs, ConfigDiff{
Path: label,
BaseVal: base.Interface(),
ActualVal: actual.Interface(),
}), nil
}
baseType := base.Type()
actualType := actual.Type()
if baseType.Kind() == reflect.Ptr {
base = reflect.Indirect(base)
actual = reflect.Indirect(actual)
baseType = base.Type()
actualType = actual.Type()
}
if baseType != actualType {
return nil, fmt.Errorf("not same type %s %s", baseType, actualType)
}
switch baseType.Kind() {
case reflect.Struct:
if base.NumField() != actual.NumField() {
return nil, fmt.Errorf("not same number of fields in struct")
}
for i := 0; i < base.NumField(); i++ {
fieldLabel := baseType.Field(i).Name
if label != "" {
fieldLabel = label + "." + fieldLabel
}
d, err := diff(base.Field(i), actual.Field(i), fieldLabel)
if err != nil {
return nil, err
}
diffs = append(diffs, d...)
}
default:
if !reflect.DeepEqual(base.Interface(), actual.Interface()) {
diffs = append(diffs, ConfigDiff{
Path: label,
BaseVal: base.Interface(),
ActualVal: actual.Interface(),
})
}
}
return diffs, nil
}
func Diff(base, actual *model.Config) (ConfigDiffs, error) {
if base == nil || actual == nil {
return nil, fmt.Errorf("input configs should not be nil")
}
baseVal := reflect.Indirect(reflect.ValueOf(base))
actualVal := reflect.Indirect(reflect.ValueOf(actual))
return diff(baseVal, actualVal, "")
}
func (cd ConfigDiffs) String() string {
return fmt.Sprintf("%+v", []ConfigDiff(cd))
}

969
server/config/diff_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,969 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package config
import (
"testing"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/stretchr/testify/require"
)
func defaultConfigGen() *model.Config {
cfg := &model.Config{}
cfg.SetDefaults()
return cfg
}
func BenchmarkDiff(b *testing.B) {
b.Run("equal empty", func(b *testing.B) {
baseCfg := &model.Config{}
actualCfg := &model.Config{}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = Diff(baseCfg, actualCfg)
}
})
b.Run("equal with defaults", func(b *testing.B) {
baseCfg := defaultConfigGen()
actualCfg := defaultConfigGen()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = Diff(baseCfg, actualCfg)
}
})
b.Run("actual empty", func(b *testing.B) {
baseCfg := defaultConfigGen()
actualCfg := &model.Config{}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = Diff(baseCfg, actualCfg)
}
})
b.Run("base empty", func(b *testing.B) {
baseCfg := &model.Config{}
actualCfg := defaultConfigGen()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = Diff(baseCfg, actualCfg)
}
})
b.Run("some diffs", func(b *testing.B) {
baseCfg := defaultConfigGen()
actualCfg := defaultConfigGen()
baseCfg.ServiceSettings.SiteURL = model.NewString("http://localhost")
baseCfg.ServiceSettings.ReadTimeout = model.NewInt(300)
baseCfg.SqlSettings.QueryTimeout = model.NewInt(0)
actualCfg.PluginSettings.EnableUploads = nil
actualCfg.TeamSettings.MaxChannelsPerTeam = model.NewInt64(100000)
actualCfg.FeatureFlags = nil
actualCfg.SqlSettings.DataSourceReplicas = []string{
"ds0",
"ds1",
"ds2",
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = Diff(baseCfg, actualCfg)
}
})
}
func TestDiffSanitized(t *testing.T) {
tcs := []struct {
name string
base *model.Config
actual *model.Config
diffs ConfigDiffs
err string
}{
{
"nil",
nil,
nil,
nil,
"input configs should not be nil",
},
{
"empty",
&model.Config{},
&model.Config{},
nil,
"",
},
{
"defaults",
defaultConfigGen(),
defaultConfigGen(),
nil,
"",
},
{
"default base, actual empty",
defaultConfigGen(),
&model.Config{},
ConfigDiffs{
{
Path: "",
BaseVal: func() model.Config {
cfg := defaultConfigGen()
cfg.Sanitize()
return *cfg
}(),
ActualVal: model.Config{},
},
},
"",
},
{
"empty base, actual default",
&model.Config{},
defaultConfigGen(),
ConfigDiffs{
{
Path: "",
BaseVal: model.Config{},
ActualVal: func() model.Config {
cfg := defaultConfigGen()
cfg.Sanitize()
return *cfg
}(),
},
},
"",
},
{
"sensitive LdapSettings.BindPassword",
func() *model.Config {
cfg := defaultConfigGen()
cfg.LdapSettings.BindPassword = model.NewString("base")
return cfg
}(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.LdapSettings.BindPassword = model.NewString("actual")
return cfg
}(),
ConfigDiffs{
{
Path: "LdapSettings.BindPassword",
BaseVal: model.FakeSetting,
ActualVal: model.FakeSetting,
},
},
"",
},
{
"sensitive FileSettings.PublicLinkSalt",
func() *model.Config {
cfg := defaultConfigGen()
cfg.FileSettings.PublicLinkSalt = model.NewString("base")
return cfg
}(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.FileSettings.PublicLinkSalt = model.NewString("actual")
return cfg
}(),
ConfigDiffs{
{
Path: "FileSettings.PublicLinkSalt",
BaseVal: model.FakeSetting,
ActualVal: model.FakeSetting,
},
},
"",
},
{
"sensitive FileSettings.AmazonS3SecretAccessKey",
func() *model.Config {
cfg := defaultConfigGen()
cfg.FileSettings.AmazonS3SecretAccessKey = model.NewString("base")
return cfg
}(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.FileSettings.AmazonS3SecretAccessKey = model.NewString("actual")
return cfg
}(),
ConfigDiffs{
{
Path: "FileSettings.AmazonS3SecretAccessKey",
BaseVal: model.FakeSetting,
ActualVal: model.FakeSetting,
},
},
"",
},
{
"sensitive SqlSettings.DataSource",
func() *model.Config {
cfg := defaultConfigGen()
cfg.SqlSettings.DataSource = model.NewString("base")
return cfg
}(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.SqlSettings.DataSource = model.NewString("actual")
return cfg
}(),
ConfigDiffs{
{
Path: "SqlSettings.DataSource",
BaseVal: model.FakeSetting,
ActualVal: model.FakeSetting,
},
},
"",
},
{
"sensitive SqlSettings.AtRestEncryptKey",
func() *model.Config {
cfg := defaultConfigGen()
cfg.SqlSettings.AtRestEncryptKey = model.NewString("base")
return cfg
}(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.SqlSettings.AtRestEncryptKey = model.NewString("actual")
return cfg
}(),
ConfigDiffs{
{
Path: "SqlSettings.AtRestEncryptKey",
BaseVal: model.FakeSetting,
ActualVal: model.FakeSetting,
},
},
"",
},
{
"sensitive SqlSettings.DataSourceReplicas",
func() *model.Config {
cfg := defaultConfigGen()
cfg.SqlSettings.DataSourceReplicas = []string{
"ds0",
"ds1",
}
return cfg
}(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.SqlSettings.DataSourceReplicas = []string{
"ds0",
"ds1",
"ds2",
}
return cfg
}(),
ConfigDiffs{
{
Path: "SqlSettings.DataSourceReplicas",
BaseVal: model.FakeSetting,
ActualVal: model.FakeSetting,
},
},
"",
},
{
"sensitive SqlSettings.DataSourceSearchReplicas",
func() *model.Config {
cfg := defaultConfigGen()
cfg.SqlSettings.DataSourceSearchReplicas = []string{
"ds0",
"ds1",
}
return cfg
}(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.SqlSettings.DataSourceSearchReplicas = []string{
"ds0",
"ds1",
"ds2",
}
return cfg
}(),
ConfigDiffs{
{
Path: "SqlSettings.DataSourceSearchReplicas",
BaseVal: model.FakeSetting,
ActualVal: model.FakeSetting,
},
},
"",
},
{
"sensitive EmailSettings.SMTPPassword",
func() *model.Config {
cfg := defaultConfigGen()
cfg.EmailSettings.SMTPPassword = model.NewString("base")
return cfg
}(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.EmailSettings.SMTPPassword = model.NewString("actual")
return cfg
}(),
ConfigDiffs{
{
Path: "EmailSettings.SMTPPassword",
BaseVal: model.FakeSetting,
ActualVal: model.FakeSetting,
},
},
"",
},
{
"sensitive GitLabSettings.Secret",
func() *model.Config {
cfg := defaultConfigGen()
cfg.GitLabSettings.Secret = model.NewString("base")
return cfg
}(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.GitLabSettings.Secret = model.NewString("actual")
return cfg
}(),
ConfigDiffs{
{
Path: "GitLabSettings.Secret",
BaseVal: model.FakeSetting,
ActualVal: model.FakeSetting,
},
},
"",
},
{
"sensitive GoogleSettings.Secret",
func() *model.Config {
cfg := defaultConfigGen()
cfg.GoogleSettings.Secret = model.NewString("base")
return cfg
}(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.GoogleSettings.Secret = model.NewString("actual")
return cfg
}(),
ConfigDiffs{
{
Path: "GoogleSettings.Secret",
BaseVal: model.FakeSetting,
ActualVal: model.FakeSetting,
},
},
"",
},
{
"sensitive Office365Settings.Secret",
func() *model.Config {
cfg := defaultConfigGen()
cfg.Office365Settings.Secret = model.NewString("base")
return cfg
}(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.Office365Settings.Secret = model.NewString("actual")
return cfg
}(),
ConfigDiffs{
{
Path: "Office365Settings.Secret",
BaseVal: model.FakeSetting,
ActualVal: model.FakeSetting,
},
},
"",
},
{
"sensitive OpenIdSettings.Secret",
func() *model.Config {
cfg := defaultConfigGen()
cfg.OpenIdSettings.Secret = model.NewString("base")
return cfg
}(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.OpenIdSettings.Secret = model.NewString("actual")
return cfg
}(),
ConfigDiffs{
{
Path: "OpenIdSettings.Secret",
BaseVal: model.FakeSetting,
ActualVal: model.FakeSetting,
},
},
"",
},
{
"sensitive ElasticsearchSettings.Password",
func() *model.Config {
cfg := defaultConfigGen()
cfg.ElasticsearchSettings.Password = model.NewString("base")
return cfg
}(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.ElasticsearchSettings.Password = model.NewString("actual")
return cfg
}(),
ConfigDiffs{
{
Path: "ElasticsearchSettings.Password",
BaseVal: model.FakeSetting,
ActualVal: model.FakeSetting,
},
},
"",
},
{
"sensitive MessageExportSettings.GlobalRelaySettings",
func() *model.Config {
cfg := defaultConfigGen()
cfg.MessageExportSettings.GlobalRelaySettings = &model.GlobalRelayMessageExportSettings{
SMTPUsername: model.NewString("base"),
SMTPPassword: model.NewString("base"),
EmailAddress: model.NewString("base"),
}
return cfg
}(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.MessageExportSettings.GlobalRelaySettings = &model.GlobalRelayMessageExportSettings{
SMTPUsername: model.NewString("actual"),
SMTPPassword: model.NewString("actual"),
EmailAddress: model.NewString("actual"),
}
return cfg
}(),
ConfigDiffs{
{
Path: "MessageExportSettings.GlobalRelaySettings.SMTPUsername",
BaseVal: model.FakeSetting,
ActualVal: model.FakeSetting,
},
{
Path: "MessageExportSettings.GlobalRelaySettings.SMTPPassword",
BaseVal: model.FakeSetting,
ActualVal: model.FakeSetting,
},
{
Path: "MessageExportSettings.GlobalRelaySettings.EmailAddress",
BaseVal: model.FakeSetting,
ActualVal: model.FakeSetting,
},
},
"",
},
{
"sensitive ServiceSettings.GfycatAPISecret",
func() *model.Config {
cfg := defaultConfigGen()
cfg.ServiceSettings.GfycatAPISecret = model.NewString("base")
return cfg
}(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.ServiceSettings.GfycatAPISecret = model.NewString("actual")
return cfg
}(),
ConfigDiffs{
{
Path: "ServiceSettings.GfycatAPISecret",
BaseVal: model.FakeSetting,
ActualVal: model.FakeSetting,
},
},
"",
},
{
"sensitive ServiceSettings.SplitKey",
func() *model.Config {
cfg := defaultConfigGen()
cfg.ServiceSettings.SplitKey = model.NewString("base")
return cfg
}(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.ServiceSettings.SplitKey = model.NewString("actual")
return cfg
}(),
ConfigDiffs{
{
Path: "ServiceSettings.SplitKey",
BaseVal: model.FakeSetting,
ActualVal: model.FakeSetting,
},
},
"",
},
{
"plugin config",
defaultConfigGen(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.PluginSettings.Plugins = map[string]map[string]any{
"com.mattermost.newplugin": {
"key": true,
},
}
return cfg
}(),
ConfigDiffs{
{
Path: "PluginSettings.Plugins",
BaseVal: model.FakeSetting,
ActualVal: model.FakeSetting,
},
},
"",
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
diffs, err := Diff(tc.base, tc.actual)
if tc.err != "" {
require.EqualError(t, err, tc.err)
require.Nil(t, diffs)
} else {
require.NoError(t, err)
}
require.Equal(t, tc.diffs, diffs.Sanitize())
})
}
}
func TestDiff(t *testing.T) {
tcs := []struct {
name string
base *model.Config
actual *model.Config
diffs ConfigDiffs
err string
}{
{
"nil",
nil,
nil,
nil,
"input configs should not be nil",
},
{
"empty",
&model.Config{},
&model.Config{},
nil,
"",
},
{
"defaults",
defaultConfigGen(),
defaultConfigGen(),
nil,
"",
},
{
"default base, actual empty",
defaultConfigGen(),
&model.Config{},
ConfigDiffs{
{
Path: "",
BaseVal: *defaultConfigGen(),
ActualVal: model.Config{},
},
},
"",
},
{
"empty base, actual default",
&model.Config{},
defaultConfigGen(),
ConfigDiffs{
{
Path: "",
BaseVal: model.Config{},
ActualVal: *defaultConfigGen(),
},
},
"",
},
{
"string change",
defaultConfigGen(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.ServiceSettings.SiteURL = model.NewString("http://changed")
return cfg
}(),
ConfigDiffs{
{
Path: "ServiceSettings.SiteURL",
BaseVal: *defaultConfigGen().ServiceSettings.SiteURL,
ActualVal: "http://changed",
},
},
"",
},
{
"string nil",
defaultConfigGen(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.ServiceSettings.SiteURL = nil
return cfg
}(),
ConfigDiffs{
{
Path: "ServiceSettings.SiteURL",
BaseVal: defaultConfigGen().ServiceSettings.SiteURL,
ActualVal: func() *string {
return nil
}(),
},
},
"",
},
{
"bool change",
defaultConfigGen(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.PluginSettings.Enable = model.NewBool(!*cfg.PluginSettings.Enable)
return cfg
}(),
ConfigDiffs{
{
Path: "PluginSettings.Enable",
BaseVal: true,
ActualVal: false,
},
},
"",
},
{
"bool nil",
defaultConfigGen(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.PluginSettings.Enable = nil
return cfg
}(),
ConfigDiffs{
{
Path: "PluginSettings.Enable",
BaseVal: defaultConfigGen().PluginSettings.Enable,
ActualVal: func() *bool {
return nil
}(),
},
},
"",
},
{
"int change",
defaultConfigGen(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.ServiceSettings.ReadTimeout = model.NewInt(0)
return cfg
}(),
ConfigDiffs{
{
Path: "ServiceSettings.ReadTimeout",
BaseVal: *defaultConfigGen().ServiceSettings.ReadTimeout,
ActualVal: 0,
},
},
"",
},
{
"int nil",
defaultConfigGen(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.ServiceSettings.ReadTimeout = nil
return cfg
}(),
ConfigDiffs{
{
Path: "ServiceSettings.ReadTimeout",
BaseVal: defaultConfigGen().ServiceSettings.ReadTimeout,
ActualVal: func() *int {
return nil
}(),
},
},
"",
},
{
"slice addition",
defaultConfigGen(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.SqlSettings.DataSourceReplicas = []string{
"ds0",
"ds1",
}
return cfg
}(),
ConfigDiffs{
{
Path: "SqlSettings.DataSourceReplicas",
BaseVal: defaultConfigGen().SqlSettings.DataSourceReplicas,
ActualVal: []string{
"ds0",
"ds1",
},
},
},
"",
},
{
"slice deletion",
func() *model.Config {
cfg := defaultConfigGen()
cfg.SqlSettings.DataSourceReplicas = []string{
"ds0",
"ds1",
}
return cfg
}(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.SqlSettings.DataSourceReplicas = []string{
"ds0",
}
return cfg
}(),
ConfigDiffs{
{
Path: "SqlSettings.DataSourceReplicas",
BaseVal: []string{
"ds0",
"ds1",
},
ActualVal: []string{
"ds0",
},
},
},
"",
},
{
"slice nil",
func() *model.Config {
cfg := defaultConfigGen()
cfg.SqlSettings.DataSourceReplicas = []string{
"ds0",
"ds1",
}
return cfg
}(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.SqlSettings.DataSourceReplicas = nil
return cfg
}(),
ConfigDiffs{
{
Path: "SqlSettings.DataSourceReplicas",
BaseVal: []string{
"ds0",
"ds1",
},
ActualVal: func() []string {
return nil
}(),
},
},
"",
},
{
"map change",
defaultConfigGen(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.PluginSettings.PluginStates["com.mattermost.nps"] = &model.PluginState{
Enable: !cfg.PluginSettings.PluginStates["com.mattermost.nps"].Enable,
}
return cfg
}(),
ConfigDiffs{
{
Path: "PluginSettings.PluginStates",
BaseVal: defaultConfigGen().PluginSettings.PluginStates,
ActualVal: map[string]*model.PluginState{
"com.mattermost.nps": {
Enable: !defaultConfigGen().PluginSettings.PluginStates["com.mattermost.nps"].Enable,
},
"focalboard": {
Enable: true,
},
"playbooks": {
Enable: true,
},
"com.mattermost.apps": {
Enable: true,
},
"com.mattermost.calls": {
Enable: true,
},
},
},
},
"",
},
{
"map addition",
defaultConfigGen(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.PluginSettings.PluginStates["com.mattermost.newplugin"] = &model.PluginState{
Enable: true,
}
return cfg
}(),
ConfigDiffs{
{
Path: "PluginSettings.PluginStates",
BaseVal: defaultConfigGen().PluginSettings.PluginStates,
ActualVal: map[string]*model.PluginState{
"com.mattermost.nps": {
Enable: defaultConfigGen().PluginSettings.PluginStates["com.mattermost.nps"].Enable,
},
"com.mattermost.newplugin": {
Enable: true,
},
"focalboard": {
Enable: true,
},
"playbooks": {
Enable: true,
},
"com.mattermost.apps": {
Enable: true,
},
"com.mattermost.calls": {
Enable: true,
},
},
},
},
"",
},
{
"map deletion",
defaultConfigGen(),
func() *model.Config {
cfg := defaultConfigGen()
delete(cfg.PluginSettings.PluginStates, "com.mattermost.nps")
return cfg
}(),
ConfigDiffs{
{
Path: "PluginSettings.PluginStates",
BaseVal: defaultConfigGen().PluginSettings.PluginStates,
ActualVal: map[string]*model.PluginState{
"focalboard": {
Enable: true,
},
"playbooks": {
Enable: true,
},
"com.mattermost.apps": {
Enable: true,
},
"com.mattermost.calls": {
Enable: true,
},
},
},
},
"",
},
{
"map nil",
defaultConfigGen(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.PluginSettings.PluginStates = nil
return cfg
}(),
ConfigDiffs{
{
Path: "PluginSettings.PluginStates",
BaseVal: defaultConfigGen().PluginSettings.PluginStates,
ActualVal: func() map[string]*model.PluginState {
return nil
}(),
},
},
"",
},
{
"map type change",
func() *model.Config {
cfg := defaultConfigGen()
cfg.PluginSettings.Plugins = map[string]map[string]any{
"com.mattermost.newplugin": {
"key": true,
},
}
return cfg
}(),
func() *model.Config {
cfg := defaultConfigGen()
cfg.PluginSettings.Plugins = map[string]map[string]any{
"com.mattermost.newplugin": {
"key": "string",
},
}
return cfg
}(),
ConfigDiffs{
{
Path: "PluginSettings.Plugins",
BaseVal: func() any {
return map[string]map[string]any{
"com.mattermost.newplugin": {
"key": true,
},
}
}(),
ActualVal: func() any {
return map[string]map[string]any{
"com.mattermost.newplugin": {
"key": "string",
},
}
}(),
},
},
"",
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
diffs, err := Diff(tc.base, tc.actual)
if tc.err != "" {
require.EqualError(t, err, tc.err)
require.Nil(t, diffs)
} else {
require.NoError(t, err)
}
require.Equal(t, tc.diffs, diffs)
})
}
}

66
server/config/emitter.go Обычный файл
Просмотреть файл

@@ -0,0 +1,66 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package config
import (
"sync"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
// Listener is a callback function invoked when the configuration changes.
type Listener func(oldCfg, newCfg *model.Config)
// 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 any) bool {
listener := value.(Listener)
listener(oldCfg, newCfg)
return true
})
}
// srcEmitter enables threadsafe registration and broadcasting to configuration listeners
type logSrcEmitter struct {
listeners sync.Map
}
// AddListener adds a callback function to invoke when the configuration is modified.
func (e *logSrcEmitter) AddListener(listener LogSrcListener) string {
id := model.NewId()
e.listeners.Store(id, listener)
return id
}
// RemoveListener removes a callback function using an id returned from AddListener.
func (e *logSrcEmitter) RemoveListener(id string) {
e.listeners.Delete(id)
}
// invokeConfigListeners synchronously notifies all listeners about the configuration change.
func (e *logSrcEmitter) invokeConfigListeners(oldCfg, newCfg mlog.LoggerConfiguration) {
e.listeners.Range(func(key, value any) bool {
listener := value.(LogSrcListener)
listener(oldCfg, newCfg)
return true
})
}

95
server/config/emitter_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,95 @@
// 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/v6/model"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
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")
}
func TestLogSrcEmitter(t *testing.T) {
var e logSrcEmitter
expectedOldCfg := make(mlog.LoggerConfiguration)
expectedNewCfg := make(mlog.LoggerConfiguration)
listener1 := false
id1 := e.AddListener(func(oldCfg, newCfg mlog.LoggerConfiguration) {
assert.Equal(t, expectedOldCfg, oldCfg)
assert.Equal(t, expectedNewCfg, newCfg)
listener1 = true
})
listener2 := false
id2 := e.AddListener(func(oldCfg, newCfg mlog.LoggerConfiguration) {
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")
}

194
server/config/environment.go Обычный файл
Просмотреть файл

@@ -0,0 +1,194 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package config
import (
"encoding/json"
"os"
"reflect"
"strconv"
"strings"
"github.com/mattermost/mattermost-server/v6/model"
)
func GetEnvironment() map[string]string {
mmenv := make(map[string]string)
for _, env := range os.Environ() {
kv := strings.SplitN(env, "=", 2)
key := strings.ToUpper(kv[0])
if strings.HasPrefix(key, "MM") {
mmenv[key] = kv[1]
}
}
return mmenv
}
func applyEnvKey(key, value string, rValueSubject reflect.Value) {
keyParts := strings.SplitN(key, "_", 2)
if len(keyParts) < 1 {
return
}
rFieldValue := rValueSubject.FieldByNameFunc(func(candidate string) bool {
candidateUpper := strings.ToUpper(candidate)
return candidateUpper == keyParts[0]
})
if !rFieldValue.IsValid() {
return
}
if rFieldValue.Kind() == reflect.Ptr {
rFieldValue = rFieldValue.Elem()
if !rFieldValue.IsValid() {
return
}
}
switch rFieldValue.Kind() {
case reflect.Struct:
// If we have only one part left, we can't deal with a struct
// the env var is incomplete so give up.
if len(keyParts) < 2 {
return
}
applyEnvKey(keyParts[1], value, rFieldValue)
case reflect.String:
rFieldValue.Set(reflect.ValueOf(value))
case reflect.Bool:
boolVal, err := strconv.ParseBool(value)
if err == nil {
rFieldValue.Set(reflect.ValueOf(boolVal))
}
case reflect.Int:
intVal, err := strconv.ParseInt(value, 10, 0)
if err == nil {
rFieldValue.Set(reflect.ValueOf(int(intVal)))
}
case reflect.Int64:
intVal, err := strconv.ParseInt(value, 10, 0)
if err == nil {
rFieldValue.Set(reflect.ValueOf(intVal))
}
case reflect.SliceOf(reflect.TypeOf("")).Kind():
rFieldValue.Set(reflect.ValueOf(strings.Split(value, " ")))
case reflect.Map:
target := reflect.New(rFieldValue.Type()).Interface()
if err := json.Unmarshal([]byte(value), target); err == nil {
rFieldValue.Set(reflect.ValueOf(target).Elem())
}
}
}
func applyEnvironmentMap(inputConfig *model.Config, env map[string]string) *model.Config {
appliedConfig := inputConfig.Clone()
rvalConfig := reflect.ValueOf(appliedConfig).Elem()
for envKey, envValue := range env {
applyEnvKey(strings.TrimPrefix(envKey, "MM_"), envValue, rvalConfig)
}
return appliedConfig
}
// generateEnvironmentMap creates a map[string]any containing true at the leaves mirroring the
// configuration structure so the client can know which env variables are overridden
func generateEnvironmentMap(env map[string]string, filter func(reflect.StructField) bool) map[string]any {
rType := reflect.TypeOf(model.Config{})
return generateEnvironmentMapWithBaseKey(env, rType, "MM", filter)
}
func generateEnvironmentMapWithBaseKey(env map[string]string, rType reflect.Type, base string, filter func(reflect.StructField) bool) map[string]any {
if rType.Kind() != reflect.Struct {
return nil
}
mapRepresentation := make(map[string]any)
for i := 0; i < rType.NumField(); i++ {
rField := rType.Field(i)
if filter != nil && !filter(rField) {
continue
}
if rField.Type.Kind() == reflect.Struct {
if val := generateEnvironmentMapWithBaseKey(env, rField.Type, base+"_"+rField.Name, filter); val != nil {
mapRepresentation[rField.Name] = val
}
} else {
if _, ok := env[strings.ToUpper(base+"_"+rField.Name)]; ok {
mapRepresentation[rField.Name] = true
}
}
}
if len(mapRepresentation) == 0 {
return nil
}
return mapRepresentation
}
// removeEnvOverrides returns a new config without the given environment overrides.
// If a config variable has an environment override, that variable is set to the value that was
// read from the store.
func removeEnvOverrides(cfg, cfgWithoutEnv *model.Config, envOverrides map[string]any) *model.Config {
paths := getPaths(envOverrides)
newCfg := cfg.Clone()
for _, path := range paths {
originalVal := getVal(cfgWithoutEnv, path)
newVal := getVal(newCfg, path)
if newVal.CanSet() {
newVal.Set(originalVal)
}
}
return newCfg
}
// getPaths turns a nested map into a slice of paths describing the keys of the map. Eg:
// map[string]map[string]map[string]bool{"this":{"is first":{"path":true}, "is second":{"path":true}))) is turned into:
// [][]string{{"this", "is first", "path"}, {"this", "is second", "path"}}
func getPaths(m map[string]any) [][]string {
return getPathsRec(m, nil)
}
// getPathsRec assembles the paths (see `getPaths` above)
func getPathsRec(src any, curPath []string) [][]string {
if srcMap, ok := src.(map[string]any); ok {
paths := [][]string{}
for k, v := range srcMap {
paths = append(paths, getPathsRec(v, append(curPath, k))...)
}
return paths
}
return [][]string{curPath}
}
// getVal walks `src` (here it starts with a model.Config, then recurses into its leaves)
// and returns the reflect.Value of the leaf at the end `path`
func getVal(src any, path []string) reflect.Value {
var val reflect.Value
// If we recursed on a Value, we already have it. If we're calling on an any, get the Value.
switch v := src.(type) {
case reflect.Value:
val = v
default:
val = reflect.ValueOf(src)
}
// Move into the struct
if val.Kind() == reflect.Ptr {
val = val.Elem().FieldByName(path[0])
} else {
val = val.FieldByName(path[0])
}
if val.Kind() == reflect.Ptr {
val = val.Elem()
}
if val.Kind() == reflect.Struct {
return getVal(val, path[1:])
}
return val
}

202
server/config/environment_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,202 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package config
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost-server/v6/model"
)
func modifiedDefault(modify func(*model.Config)) *model.Config {
def := defaultConfig()
modify(def)
return def
}
func defaultConfig() *model.Config {
def := &model.Config{}
def.SetDefaults()
return def
}
func TestRemoveEnvOverrides(t *testing.T) {
var tests = []struct {
name string
inputConfig *model.Config
env map[string]string
expectedConfig *model.Config
}{
{
name: "config override",
inputConfig: modifiedDefault(func(in *model.Config) {
*in.ServiceSettings.TLSMinVer = "1.4"
in.PluginSettings.PluginStates = map[string]*model.PluginState{
"plugin1": {
Enable: false,
},
}
in.PluginSettings.Plugins = map[string]map[string]interface{}{
"com.mattermost.plugin-1": {
"key1": "value1",
},
"com_mattermost_plugin-2": {
"key2": "value2",
},
}
}),
env: map[string]string{
"MM_SERVICESETTINGS_TLSMINVER": "1.5",
"MM_PLUGINSETTINGS_PLUGINSTATES": `{
"plugin1": {
"Enable": true
}
}`,
"MM_PLUGINSETTINGS_PLUGINS": `{
"com.mattermost.plugin-1": {
"key1": "other-value"
},
"com_mattermost_plugin-2": {
"key2": "other-value"
}
}`,
},
expectedConfig: modifiedDefault(func(in *model.Config) {
*in.ServiceSettings.TLSMinVer = "1.5"
in.PluginSettings.PluginStates = map[string]*model.PluginState{
"plugin1": {
Enable: true,
},
}
in.PluginSettings.Plugins = map[string]map[string]interface{}{
"com.mattermost.plugin-1": {
"key1": "other-value",
},
"com_mattermost_plugin-2": {
"key2": "other-value",
},
}
}),
},
{
name: "feature flags",
inputConfig: modifiedDefault(func(in *model.Config) {
in.FeatureFlags.TestFeature = "somevalue"
}),
env: map[string]string{
"MM_FEATUREFLAGS_TESTFEATURE": "correctvalue",
},
expectedConfig: modifiedDefault(func(in *model.Config) {
in.FeatureFlags.TestFeature = "correctvalue"
}),
},
{
name: "int setting",
inputConfig: modifiedDefault(func(in *model.Config) {
*in.ClusterSettings.GossipPort = 500
}),
env: map[string]string{
"MM_CLUSTERSETTINGS_GOSSIPPORT": "600",
},
expectedConfig: modifiedDefault(func(in *model.Config) {
*in.ClusterSettings.GossipPort = 600
}),
},
{
name: "int64 setting",
inputConfig: modifiedDefault(func(in *model.Config) {
*in.ServiceSettings.TLSStrictTransportMaxAge = 500
}),
env: map[string]string{
"MM_SERVICESETTINGS_TLSSTRICTTRANSPORTMAXAGE": "4294967294",
},
expectedConfig: modifiedDefault(func(in *model.Config) {
*in.ServiceSettings.TLSStrictTransportMaxAge = 4294967294
}),
},
{
name: "bool setting",
inputConfig: modifiedDefault(func(in *model.Config) {
*in.ClusterSettings.UseIPAddress = false
}),
env: map[string]string{
"MM_CLUSTERSETTINGS_USEIPADDRESS": "true",
},
expectedConfig: modifiedDefault(func(in *model.Config) {
*in.ClusterSettings.UseIPAddress = true
}),
},
{
name: "[]string setting",
inputConfig: modifiedDefault(func(in *model.Config) {
in.SqlSettings.DataSourceReplicas = []string{"something"}
}),
env: map[string]string{
"MM_SQLSETTINGS_DATASOURCEREPLICAS": "otherthing alsothis",
},
expectedConfig: modifiedDefault(func(in *model.Config) {
in.SqlSettings.DataSourceReplicas = []string{"otherthing", "alsothis"}
}),
},
{
name: "complex env settings",
inputConfig: modifiedDefault(func(in *model.Config) {
}),
env: map[string]string{
"MM_PLUGINSETTINGS_PLUGINSTATES": `{
"com.mattermost.plugin-1": {
"enable": true
}
}`,
"MM_PLUGINSETTINGS_PLUGINS": `{
"com.mattermost.plugin-1": {
"key": {
"key": "(?P<key>KEY)-(?P<id>\\d{1,6})(?P<comma>[,;]*)",
"value": "[$key-$id](https://example.com/?$project-$id)$comma"
}
}
}`,
},
expectedConfig: modifiedDefault(func(in *model.Config) {
in.PluginSettings.PluginStates = map[string]*model.PluginState{
"com.mattermost.plugin-1": {
Enable: true,
},
}
in.PluginSettings.Plugins = map[string]map[string]interface{}{
"com.mattermost.plugin-1": {
"key": map[string]interface{}{
"key": "(?P<key>KEY)-(?P<id>\\d{1,6})(?P<comma>[,;]*)",
"value": "[$key-$id](https://example.com/?$project-$id)$comma",
},
},
}
}),
},
{
name: "bad env",
inputConfig: modifiedDefault(func(in *model.Config) {
}),
env: map[string]string{
"MM_SERVICESETTINGS": "huh?",
"NOTMM": "huh?",
"MM_NOTEXIST": "huh?",
"MM_NOTEXIST_MORE_AND_MORE": "huh?",
"MM_": "huh?",
"MM": "huh?",
"MM__": "huh?",
"_": "huh?",
},
expectedConfig: modifiedDefault(func(in *model.Config) {
}),
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
require.Equal(t, testCase.expectedConfig, applyEnvironmentMap(testCase.inputConfig, testCase.env))
})
}
}

223
server/config/file.go Обычный файл
Просмотреть файл

@@ -0,0 +1,223 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package config
import (
"fmt"
"io"
"os"
"path/filepath"
"github.com/pkg/errors"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/channels/utils/fileutils"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
var (
// ErrReadOnlyConfiguration is returned when an attempt to modify a read-only configuration is made.
ErrReadOnlyConfiguration = errors.New("configuration is read-only")
)
// FileStore is a config store backed by a file such as config/config.json.
//
// It also uses the folder containing the configuration file for storing other configuration files.
// Not to be used directly. Only to be used as a backing store for config.Store
type FileStore struct {
path string
}
// NewFileStore creates a new instance of a config store backed by the given file path.
func NewFileStore(path string, createFileIfNotExists bool) (fs *FileStore, err error) {
resolvedPath, err := resolveConfigFilePath(path)
if err != nil {
return nil, err
}
f, err := os.Open(resolvedPath)
if err != nil && errors.Is(err, os.ErrNotExist) && createFileIfNotExists {
file, err2 := os.Create(resolvedPath)
if err2 != nil {
return nil, fmt.Errorf("could not create config file: %w", err2)
}
defer file.Close()
} else if err != nil {
return nil, err
} else {
defer f.Close()
}
return &FileStore{
path: resolvedPath,
}, 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 relative path to the file in the channels/config folder, taking into account
// various common starting points.
if configFile := fileutils.FindFile(filepath.Join("channels/config", path)); configFile != "" {
return configFile, nil
}
// Search for the relative path to the file in the config folder, taking into account
// various common starting points.
if configFile := fileutils.FindFile(filepath.Join("config", path)); configFile != "" {
return configFile, nil
}
// Search for the relative path in the current working directory, also taking into account
// various common starting points.
if configFile := fileutils.FindPath(path, []string{"."}, nil); configFile != "" {
return configFile, nil
}
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)
}
// resolveFilePath uses the name if name is absolute path.
// otherwise returns the combined path/name
func (fs *FileStore) resolveFilePath(name string) string {
// Absolute paths are explicit and require no resolution.
if filepath.IsAbs(name) {
return name
}
return filepath.Join(filepath.Dir(fs.path), name)
}
// Set replaces the current configuration in its entirety and updates the backing store.
func (fs *FileStore) Set(newCfg *model.Config) error {
if *newCfg.ClusterSettings.Enable && *newCfg.ClusterSettings.ReadOnlyConfig {
return ErrReadOnlyConfiguration
}
return fs.persist(newCfg)
}
// persist writes the configuration to the configured file.
func (fs *FileStore) persist(cfg *model.Config) error {
b, err := marshalConfig(cfg)
if err != nil {
return errors.Wrap(err, "failed to serialize")
}
err = os.WriteFile(fs.path, b, 0600)
if err != nil {
return errors.Wrap(err, "failed to write file")
}
return nil
}
// Load updates the current configuration from the backing store.
func (fs *FileStore) Load() ([]byte, error) {
f, err := os.Open(fs.path)
if os.IsNotExist(err) {
return nil, nil
} else if err != nil {
return nil, errors.Wrapf(err, "failed to open %s for reading", fs.path)
}
defer f.Close()
fileBytes, err := io.ReadAll(f)
if err != nil {
return nil, err
}
return fileBytes, nil
}
// GetFile fetches the contents of a previously persisted configuration file.
func (fs *FileStore) GetFile(name string) ([]byte, error) {
resolvedPath := fs.resolveFilePath(name)
data, err := os.ReadFile(resolvedPath)
if err != nil {
return nil, errors.Wrapf(err, "failed to read file from %s", resolvedPath)
}
return data, nil
}
// GetFilePath returns the resolved path of a configuration file.
// The file may not necessarily exist.
func (fs *FileStore) GetFilePath(name string) string {
return fs.resolveFilePath(name)
}
// SetFile sets or replaces the contents of a configuration file.
func (fs *FileStore) SetFile(name string, data []byte) error {
resolvedPath := fs.resolveFilePath(name)
err := os.WriteFile(resolvedPath, data, 0600)
if err != nil {
return errors.Wrapf(err, "failed to write file to %s", resolvedPath)
}
return nil
}
// HasFile returns true if the given file was previously persisted.
func (fs *FileStore) HasFile(name string) (bool, error) {
if name == "" {
return false, nil
}
resolvedPath := fs.resolveFilePath(name)
_, err := os.Stat(resolvedPath)
if err != nil && os.IsNotExist(err) {
return false, nil
} else if err != nil {
return false, errors.Wrap(err, "failed to check if file exists")
}
return true, nil
}
// RemoveFile removes a previously persisted configuration file.
func (fs *FileStore) RemoveFile(name string) error {
if filepath.IsAbs(name) {
// Don't delete absolute filenames, as may be mounted drive, etc.
mlog.Debug("Skipping removal of configuration file with absolute path", mlog.String("filename", name))
return nil
}
resolvedPath := filepath.Join(filepath.Dir(fs.path), name)
err := os.Remove(resolvedPath)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return errors.Wrap(err, "failed to remove file")
}
return 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 {
return nil
}

1353
server/config/file_test.go Обычный файл

Разница между файлами не показана из-за своего большого размера Загрузить разницу

165
server/config/logconfigsrc.go Обычный файл
Просмотреть файл

@@ -0,0 +1,165 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package config
import (
"encoding/json"
"errors"
"path/filepath"
"strings"
"sync"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
type LogSrcListener func(old, new mlog.LoggerConfiguration)
// LogConfigSrc abstracts the Advanced Logging configuration so that implementations can
// fetch from file, database, etc.
type LogConfigSrc interface {
// Get fetches the current, cached configuration.
Get() mlog.LoggerConfiguration
// Set updates the dsn specifying the source and reloads
Set(dsn string, configStore *Store) (err error)
// Close cleans up resources.
Close() error
}
// NewLogConfigSrc creates an advanced logging configuration source, backed by a
// file, JSON string, or database.
func NewLogConfigSrc(dsn string, configStore *Store) (LogConfigSrc, error) {
if dsn == "" {
return nil, errors.New("dsn should not be empty")
}
if configStore == nil {
return nil, errors.New("configStore should not be nil")
}
dsn = strings.TrimSpace(dsn)
if isJSONMap(dsn) {
return newJSONSrc(dsn)
}
path := dsn
// If this is a file based config we need the full path so it can be watched.
if strings.HasPrefix(configStore.String(), "file://") && !filepath.IsAbs(dsn) {
configPath := strings.TrimPrefix(configStore.String(), "file://")
path = filepath.Join(filepath.Dir(configPath), dsn)
}
return newFileSrc(path, configStore)
}
// jsonSrc
type jsonSrc struct {
logSrcEmitter
mutex sync.RWMutex
cfg mlog.LoggerConfiguration
}
func newJSONSrc(data string) (*jsonSrc, error) {
src := &jsonSrc{}
return src, src.Set(data, nil)
}
// Get fetches the current, cached configuration
func (src *jsonSrc) Get() mlog.LoggerConfiguration {
src.mutex.RLock()
defer src.mutex.RUnlock()
return src.cfg
}
// Set updates the JSON specifying the source and reloads
func (src *jsonSrc) Set(data string, _ *Store) error {
cfg, err := logTargetCfgFromJSON([]byte(data))
if err != nil {
return err
}
src.set(cfg)
return nil
}
func (src *jsonSrc) set(cfg mlog.LoggerConfiguration) {
src.mutex.Lock()
defer src.mutex.Unlock()
old := src.cfg
src.cfg = cfg
src.invokeConfigListeners(old, cfg)
}
// Close cleans up resources.
func (src *jsonSrc) Close() error {
return nil
}
// fileSrc
type fileSrc struct {
mutex sync.RWMutex
cfg mlog.LoggerConfiguration
path string
}
func newFileSrc(path string, configStore *Store) (*fileSrc, error) {
src := &fileSrc{
path: path,
}
if err := src.Set(path, configStore); err != nil {
return nil, err
}
return src, nil
}
// Get fetches the current, cached configuration
func (src *fileSrc) Get() mlog.LoggerConfiguration {
src.mutex.RLock()
defer src.mutex.RUnlock()
return src.cfg
}
// Set updates the dsn specifying the file source and reloads.
// The file will be watched for changes and reloaded as needed,
// and all listeners notified.
func (src *fileSrc) Set(path string, configStore *Store) error {
data, err := configStore.GetFile(path)
if err != nil {
return err
}
cfg, err := logTargetCfgFromJSON(data)
if err != nil {
return err
}
src.set(cfg)
return nil
}
func (src *fileSrc) set(cfg mlog.LoggerConfiguration) {
src.mutex.Lock()
defer src.mutex.Unlock()
src.cfg = cfg
}
// Close cleans up resources.
func (src *fileSrc) Close() error {
return nil
}
func logTargetCfgFromJSON(data []byte) (mlog.LoggerConfiguration, error) {
cfg := make(mlog.LoggerConfiguration)
err := json.Unmarshal(data, &cfg)
if err != nil {
return nil, err
}
return cfg, nil
}

49
server/config/logconfigsrc_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,49 @@
// 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/stretchr/testify/require"
)
const (
validJSON = `{"file":{ "Type":"file"}}`
badJSON = `{"file":{ Type="file"}}`
)
func TestNewLogConfigSrc(t *testing.T) {
store := NewTestMemoryStore()
require.NotNil(t, store)
err := store.SetFile("advancedlogging.conf", []byte(validJSON))
require.NoError(t, err)
tests := []struct {
name string
dsn string
configStore *Store
wantErr bool
wantType LogConfigSrc
}{
{name: "empty dsn", dsn: "", configStore: store, wantErr: true, wantType: nil},
{name: "garbage dsn", dsn: "!@wfejwcevioj", configStore: store, wantErr: true, wantType: nil},
{name: "valid json dsn", dsn: validJSON, configStore: store, wantErr: false, wantType: &jsonSrc{}},
{name: "invalid json dsn", dsn: badJSON, configStore: store, wantErr: true, wantType: nil},
{name: "valid filespec dsn", dsn: "advancedlogging.conf", configStore: store, wantErr: false, wantType: &fileSrc{}},
{name: "invalid filespec dsn", dsn: "/nobody/here.conf", configStore: store, wantErr: true, wantType: nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := NewLogConfigSrc(tt.dsn, tt.configStore)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.IsType(t, tt.wantType, got)
}
})
}
}

238
server/config/logger.go Обычный файл
Просмотреть файл

@@ -0,0 +1,238 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package config
import (
"encoding/json"
"fmt"
"path/filepath"
"strings"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/channels/utils/fileutils"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
const (
LogRotateSizeMB = 100
LogCompress = true
LogRotateMaxAge = 0
LogRotateMaxBackups = 0
LogFilename = "mattermost.log"
LogNotificationFilename = "notifications.log"
LogMinLevelLen = 5
LogMinMsgLen = 45
LogDelim = " "
LogEnableCaller = true
)
type fileLocationFunc func(string) string
func MloggerConfigFromLoggerConfig(s *model.LogSettings, configSrc LogConfigSrc, getFileFunc fileLocationFunc) (mlog.LoggerConfiguration, error) {
cfg := make(mlog.LoggerConfiguration)
var targetCfg mlog.TargetCfg
var err error
// add the simple logging config
if *s.EnableConsole {
targetCfg, err = makeSimpleConsoleTarget(*s.ConsoleLevel, *s.ConsoleJson, *s.EnableColor)
if err != nil {
return cfg, err
}
cfg["_defConsole"] = targetCfg
}
if *s.EnableFile {
targetCfg, err = makeSimpleFileTarget(getFileFunc(*s.FileLocation), *s.FileLevel, *s.FileJson)
if err != nil {
return cfg, err
}
cfg["_defFile"] = targetCfg
}
if configSrc == nil {
return cfg, nil
}
// add advanced logging config
cfgAdv := configSrc.Get()
cfg.Append(cfgAdv)
return cfg, nil
}
func MloggerConfigFromAuditConfig(auditSettings model.ExperimentalAuditSettings, configSrc LogConfigSrc) (mlog.LoggerConfiguration, error) {
cfg := make(mlog.LoggerConfiguration)
var targetCfg mlog.TargetCfg
var err error
// add the simple audit config
if *auditSettings.FileEnabled {
targetCfg, err = makeSimpleFileTarget(*auditSettings.FileName, "error", true)
if err != nil {
return nil, err
}
// apply audit specific levels
targetCfg.Levels = []mlog.Level{mlog.LvlAuditAPI, mlog.LvlAuditContent, mlog.LvlAuditPerms, mlog.LvlAuditCLI}
// apply audit specific formatting
targetCfg.FormatOptions = json.RawMessage(`{"disable_timestamp": false, "disable_msg": true, "disable_stacktrace": true, "disable_level": true}`)
cfg["_defAudit"] = targetCfg
}
if configSrc == nil {
return cfg, nil
}
// add advanced audit config
cfgAdv := configSrc.Get()
cfg.Append(cfgAdv)
return cfg, nil
}
func GetLogFileLocation(fileLocation string) string {
if fileLocation == "" {
fileLocation, _ = fileutils.FindDir("logs")
}
return filepath.Join(fileLocation, LogFilename)
}
func GetNotificationsLogFileLocation(fileLocation string) string {
if fileLocation == "" {
fileLocation, _ = fileutils.FindDir("logs")
}
return filepath.Join(fileLocation, LogNotificationFilename)
}
func GetLogSettingsFromNotificationsLogSettings(notificationLogSettings *model.NotificationLogSettings) *model.LogSettings {
settings := &model.LogSettings{}
settings.SetDefaults()
settings.ConsoleJson = notificationLogSettings.ConsoleJson
settings.ConsoleLevel = notificationLogSettings.ConsoleLevel
settings.EnableConsole = notificationLogSettings.EnableConsole
settings.EnableFile = notificationLogSettings.EnableFile
settings.FileJson = notificationLogSettings.FileJson
settings.FileLevel = notificationLogSettings.FileLevel
settings.FileLocation = notificationLogSettings.FileLocation
settings.AdvancedLoggingConfig = notificationLogSettings.AdvancedLoggingConfig
settings.EnableColor = notificationLogSettings.EnableColor
return settings
}
func makeSimpleConsoleTarget(level string, outputJSON bool, color bool) (mlog.TargetCfg, error) {
levels, err := stdLevels(level)
if err != nil {
return mlog.TargetCfg{}, err
}
target := mlog.TargetCfg{
Type: "console",
Levels: levels,
Options: json.RawMessage(`{"out": "stdout"}`),
MaxQueueSize: 1000,
}
if outputJSON {
target.Format = "json"
target.FormatOptions = makeJSONFormatOptions()
} else {
target.Format = "plain"
target.FormatOptions = makePlainFormatOptions(color)
}
return target, nil
}
func makeSimpleFileTarget(filename string, level string, json bool) (mlog.TargetCfg, error) {
levels, err := stdLevels(level)
if err != nil {
return mlog.TargetCfg{}, err
}
fileOpts, err := makeFileOptions(filename)
if err != nil {
return mlog.TargetCfg{}, fmt.Errorf("cannot encode file options: %w", err)
}
target := mlog.TargetCfg{
Type: "file",
Levels: levels,
Options: fileOpts,
MaxQueueSize: 1000,
}
if json {
target.Format = "json"
target.FormatOptions = makeJSONFormatOptions()
} else {
target.Format = "plain"
target.FormatOptions = makePlainFormatOptions(false)
}
return target, nil
}
func stdLevels(level string) ([]mlog.Level, error) {
stdLevel, err := stringToStdLevel(level)
if err != nil {
return nil, err
}
var levels []mlog.Level
for _, l := range mlog.StdAll {
if l.ID <= stdLevel.ID {
levels = append(levels, l)
}
}
return levels, nil
}
func stringToStdLevel(level string) (mlog.Level, error) {
level = strings.ToLower(level)
for _, l := range mlog.StdAll {
if l.Name == level {
return l, nil
}
}
return mlog.Level{}, fmt.Errorf("%s is not a standard level", level)
}
func makeJSONFormatOptions() json.RawMessage {
str := fmt.Sprintf(`{"enable_caller": %t}`, LogEnableCaller)
return json.RawMessage(str)
}
func makePlainFormatOptions(enableColor bool) json.RawMessage {
str := fmt.Sprintf(`{"delim": "%s", "min_level_len": %d, "min_msg_len": %d, "enable_color": %t, "enable_caller": %t}`,
LogDelim, LogMinLevelLen, LogMinMsgLen, enableColor, LogEnableCaller)
return json.RawMessage(str)
}
func makeFileOptions(filename string) (json.RawMessage, error) {
opts := struct {
Filename string `json:"filename"`
Max_size int `json:"max_size"`
Max_age int `json:"max_age"`
Max_backups int `json:"max_backups"`
Compress bool `json:"compress"`
}{
Filename: filename,
Max_size: LogRotateSizeMB,
Max_age: LogRotateMaxAge,
Max_backups: LogRotateMaxBackups,
Compress: LogCompress,
}
b, err := json.Marshal(opts)
if err != nil {
return nil, err
}
return json.RawMessage(b), nil
}

53
server/config/logger_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,53 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package config
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
func TestMloggerConfigFromAuditConfig(t *testing.T) {
auditSettings := model.ExperimentalAuditSettings{
FileEnabled: model.NewBool(true),
FileName: model.NewString("audit.log"),
FileMaxSizeMB: model.NewInt(20),
FileMaxAgeDays: model.NewInt(1),
FileMaxBackups: model.NewInt(5),
FileCompress: model.NewBool(true),
FileMaxQueueSize: model.NewInt(5000),
}
t.Run("validate default audit settings", func(t *testing.T) {
cfg, err := MloggerConfigFromAuditConfig(auditSettings, nil)
require.NoError(t, err, "audit config should not error")
require.Len(t, cfg, 1, "default audit config should have one target")
targetCfg := cfg["_defAudit"]
// check general
assert.Equal(t, targetCfg.Type, "file")
assert.Equal(t, targetCfg.Format, "json")
assert.ElementsMatch(t, targetCfg.Levels, []mlog.Level{mlog.LvlAuditAPI, mlog.LvlAuditContent, mlog.LvlAuditPerms, mlog.LvlAuditCLI})
// check format options
optionsExpected := map[string]any{
"disable_timestamp": false,
"disable_msg": true,
"disable_stacktrace": true,
"disable_level": true,
}
var optionsReceived map[string]any
err = json.Unmarshal(targetCfg.FormatOptions, &optionsReceived)
require.NoError(t, err, "unmarshal should not fail")
assert.Equal(t, optionsExpected, optionsReceived)
})
}

74
server/config/main_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package config
import (
"fmt"
"testing"
"github.com/go-sql-driver/mysql"
"github.com/lib/pq"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/channels/testlib"
)
var mainHelper *testlib.MainHelper
func TestMain(m *testing.M) {
var options = testlib.HelperOptions{
EnableStore: true,
}
mainHelper = testlib.NewMainHelperWithOptions(&options)
defer mainHelper.Close()
mainHelper.Main(m)
}
// truncateTable clears the given table
func truncateTable(t *testing.T, table string) {
t.Helper()
sqlSetting := mainHelper.GetSQLSettings()
sqlStore := mainHelper.GetSQLStore()
switch *sqlSetting.DriverName {
case model.DatabaseDriverMysql:
_, err := sqlStore.GetMasterX().Exec(fmt.Sprintf("TRUNCATE TABLE %s", table))
if err != nil {
if driverErr, ok := err.(*mysql.MySQLError); ok {
// Ignore if the Configurations table does not exist.
if driverErr.Number == 1146 {
return
}
}
}
require.NoError(t, err)
case model.DatabaseDriverPostgres:
_, err := sqlStore.GetMasterX().Exec(fmt.Sprintf("TRUNCATE TABLE %s", table))
if err != nil {
if driverErr, ok := err.(*pq.Error); ok {
// Ignore if the Configurations table does not exist.
if driverErr.Code == "42P01" {
return
}
}
}
require.NoError(t, err)
default:
require.Failf(t, "failed", "unsupported driver name: %s", *sqlSetting.DriverName)
}
}
// truncateTables clears tables used by the config package for reuse in other tests
func truncateTables(t *testing.T) {
t.Helper()
truncateTable(t, "Configurations")
truncateTable(t, "ConfigurationFiles")
truncateTable(t, migrationsTableName)
}

120
server/config/memory.go Обычный файл
Просмотреть файл

@@ -0,0 +1,120 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package config
import (
"fmt"
"github.com/pkg/errors"
"github.com/mattermost/mattermost-server/v6/model"
)
// MemoryStore implements the Store interface. It is meant primarily for testing.
// Not to be used directly. Only to be used as a backing store for config.Store
type MemoryStore struct {
allowEnvironmentOverrides bool
validate bool
files map[string][]byte
savedConfig *model.Config
}
// MemoryStoreOptions makes configuration of the memory store explicit.
type MemoryStoreOptions struct {
IgnoreEnvironmentOverrides bool
SkipValidation bool
InitialConfig *model.Config
InitialFiles map[string][]byte
}
// NewMemoryStore creates a new MemoryStore instance with default options.
func NewMemoryStore() (*MemoryStore, error) {
return NewMemoryStoreWithOptions(&MemoryStoreOptions{})
}
// NewMemoryStoreWithOptions creates a new MemoryStore instance.
func NewMemoryStoreWithOptions(options *MemoryStoreOptions) (*MemoryStore, error) {
savedConfig := options.InitialConfig
if savedConfig == nil {
savedConfig = &model.Config{}
savedConfig.SetDefaults()
}
initialFiles := options.InitialFiles
if initialFiles == nil {
initialFiles = make(map[string][]byte)
}
ms := &MemoryStore{
allowEnvironmentOverrides: !options.IgnoreEnvironmentOverrides,
validate: !options.SkipValidation,
files: initialFiles,
savedConfig: savedConfig,
}
return ms, nil
}
// Set replaces the current configuration in its entirety.
func (ms *MemoryStore) Set(newCfg *model.Config) error {
return ms.persist(newCfg)
}
// persist copies the active config to the saved config.
func (ms *MemoryStore) persist(cfg *model.Config) error {
ms.savedConfig = cfg.Clone()
return nil
}
// Load applies environment overrides to the default config as if a re-load had occurred.
func (ms *MemoryStore) Load() ([]byte, error) {
cfgBytes, err := marshalConfig(ms.savedConfig)
if err != nil {
return nil, errors.Wrap(err, "failed to serialize config")
}
return cfgBytes, nil
}
// GetFile fetches the contents of a previously persisted configuration file.
func (ms *MemoryStore) GetFile(name string) ([]byte, error) {
data, ok := ms.files[name]
if !ok {
return nil, fmt.Errorf("file %s not stored", name)
}
return data, nil
}
// SetFile sets or replaces the contents of a configuration file.
func (ms *MemoryStore) SetFile(name string, data []byte) error {
ms.files[name] = data
return nil
}
// HasFile returns true if the given file was previously persisted.
func (ms *MemoryStore) HasFile(name string) (bool, error) {
_, ok := ms.files[name]
return ok, nil
}
// RemoveFile removes a previously persisted configuration file.
func (ms *MemoryStore) RemoveFile(name string) error {
delete(ms.files, name)
return nil
}
// String returns a hard-coded description, as there is no backing store.
func (ms *MemoryStore) String() string {
return "memory://"
}
// Close does nothing for a memory store.
func (ms *MemoryStore) Close() error {
return nil
}

205
server/config/memory_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,205 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package config
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupConfigMemory(t *testing.T) {
t.Helper()
os.Clearenv()
}
func TestMemoryGetFile(t *testing.T) {
setupConfigMemory(t)
ms, err := NewMemoryStoreWithOptions(&MemoryStoreOptions{
InitialConfig: minimalConfig,
InitialFiles: map[string][]byte{
"empty-file": {},
"test-file": []byte("test"),
},
})
require.NoError(t, err)
defer ms.Close()
t.Run("get empty filename", func(t *testing.T) {
_, err := ms.GetFile("")
require.Error(t, err)
})
t.Run("get non-existent file", func(t *testing.T) {
_, err := ms.GetFile("unknown")
require.Error(t, err)
})
t.Run("get empty file", func(t *testing.T) {
data, err := ms.GetFile("empty-file")
require.NoError(t, err)
require.Empty(t, data)
})
t.Run("get non-empty file", func(t *testing.T) {
data, err := ms.GetFile("test-file")
require.NoError(t, err)
require.Equal(t, []byte("test"), data)
})
}
func TestMemorySetFile(t *testing.T) {
setupConfigMemory(t)
ms, err := NewMemoryStoreWithOptions(&MemoryStoreOptions{
InitialConfig: minimalConfig,
})
require.NoError(t, err)
defer ms.Close()
t.Run("set new file", func(t *testing.T) {
err := ms.SetFile("new", []byte("new file"))
require.NoError(t, err)
data, err := ms.GetFile("new")
require.NoError(t, err)
require.Equal(t, []byte("new file"), data)
})
t.Run("overwrite existing file", func(t *testing.T) {
err := ms.SetFile("existing", []byte("existing file"))
require.NoError(t, err)
err = ms.SetFile("existing", []byte("overwritten file"))
require.NoError(t, err)
data, err := ms.GetFile("existing")
require.NoError(t, err)
require.Equal(t, []byte("overwritten file"), data)
})
}
func TestMemoryHasFile(t *testing.T) {
t.Run("has non-existent", func(t *testing.T) {
setupConfigMemory(t)
ms, err := NewMemoryStoreWithOptions(&MemoryStoreOptions{
InitialConfig: minimalConfig,
})
require.NoError(t, err)
defer ms.Close()
has, err := ms.HasFile("non-existent")
require.NoError(t, err)
require.False(t, has)
})
t.Run("has existing", func(t *testing.T) {
setupConfigMemory(t)
ms, err := NewMemoryStoreWithOptions(&MemoryStoreOptions{
InitialConfig: minimalConfig,
})
require.NoError(t, err)
defer ms.Close()
err = ms.SetFile("existing", []byte("existing file"))
require.NoError(t, err)
has, err := ms.HasFile("existing")
require.NoError(t, err)
require.True(t, has)
})
t.Run("has manually created file", func(t *testing.T) {
setupConfigMemory(t)
ms, err := NewMemoryStoreWithOptions(&MemoryStoreOptions{
InitialConfig: minimalConfig,
InitialFiles: map[string][]byte{
"manual": []byte("manual file"),
},
})
require.NoError(t, err)
defer ms.Close()
has, err := ms.HasFile("manual")
require.NoError(t, err)
require.True(t, has)
})
}
func TestMemoryRemoveFile(t *testing.T) {
t.Run("remove non-existent", func(t *testing.T) {
setupConfigMemory(t)
ms, err := NewMemoryStoreWithOptions(&MemoryStoreOptions{
InitialConfig: minimalConfig,
})
require.NoError(t, err)
defer ms.Close()
err = ms.RemoveFile("non-existent")
require.NoError(t, err)
})
t.Run("remove existing", func(t *testing.T) {
setupConfigMemory(t)
ms, err := NewMemoryStoreWithOptions(&MemoryStoreOptions{
InitialConfig: minimalConfig,
})
require.NoError(t, err)
defer ms.Close()
err = ms.SetFile("existing", []byte("existing file"))
require.NoError(t, err)
err = ms.RemoveFile("existing")
require.NoError(t, err)
has, err := ms.HasFile("existing")
require.NoError(t, err)
require.False(t, has)
_, err = ms.GetFile("existing")
require.Error(t, err)
})
t.Run("remove manually created file", func(t *testing.T) {
setupConfigMemory(t)
ms, err := NewMemoryStoreWithOptions(&MemoryStoreOptions{
InitialConfig: minimalConfig,
InitialFiles: map[string][]byte{
"manual": []byte("manual file"),
},
})
require.NoError(t, err)
defer ms.Close()
err = ms.RemoveFile("manual")
require.NoError(t, err)
has, err := ms.HasFile("manual")
require.NoError(t, err)
require.False(t, has)
_, err = ms.GetFile("manual")
require.Error(t, err)
})
}
func TestMemoryStoreString(t *testing.T) {
setupConfigMemory(t)
ms, err := NewMemoryStoreWithOptions(&MemoryStoreOptions{InitialConfig: emptyConfig})
require.NoError(t, err)
defer ms.Close()
assert.Equal(t, "memory://", ms.String())
}

69
server/config/migrate.go Обычный файл
Просмотреть файл

@@ -0,0 +1,69 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package config
import (
"github.com/pkg/errors"
)
// Migrate migrates SAML keys, certificates, and other config files from one store to another given their data source names.
func Migrate(from, to string) error {
source, err := NewStoreFromDSN(from, false, nil, false)
if err != nil {
return errors.Wrapf(err, "failed to access source config %s", from)
}
defer source.Close()
destination, err := NewStoreFromDSN(to, false, nil, true)
if err != nil {
return errors.Wrapf(err, "failed to access destination config %s", to)
}
defer destination.Close()
sourceConfig := source.Get()
if _, _, err = destination.Set(sourceConfig); err != nil {
return errors.Wrapf(err, "failed to set config")
}
files := []string{
*sourceConfig.SamlSettings.IdpCertificateFile,
*sourceConfig.SamlSettings.PublicCertificateFile,
*sourceConfig.SamlSettings.PrivateKeyFile,
}
// Only migrate advanced logging config if it is not embedded JSON.
if !isJSONMap(*sourceConfig.LogSettings.AdvancedLoggingConfig) {
files = append(files, *sourceConfig.LogSettings.AdvancedLoggingConfig)
}
files = append(files, sourceConfig.PluginSettings.SignaturePublicKeyFiles...)
for _, file := range files {
if err := migrateFile(file, source, destination); err != nil {
return err
}
}
return nil
}
func migrateFile(name string, source *Store, destination *Store) error {
fileExists, err := source.HasFile(name)
if err != nil {
return errors.Wrapf(err, "failed to check existence of %s", name)
}
if fileExists {
file, err := source.GetFile(name)
if err != nil {
return errors.Wrapf(err, "failed to migrate %s", name)
}
err = destination.SetFile(name, file)
if err != nil {
return errors.Wrapf(err, "failed to migrate %s", name)
}
}
return nil
}

162
server/config/migrate_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,162 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package config
import (
"os"
"path"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost-server/v6/model"
)
type cleanUpFn func(store *Store)
func TestMigrate(t *testing.T) {
if testing.Short() {
t.Skip("skipping migration test in short mode")
}
files := []string{
"IdpCertificateFile",
"PublicCertificateFile",
"PrivateKeyFile",
"internal.crt",
"internal2.crt",
}
filesData := make([]string, len(files))
for i := range files {
// Generate random data for each file, ensuring that stale data from a past test
// won't generate a false positive.
filesData[i] = model.NewId()
}
setup := func(t *testing.T) {
os.Clearenv()
t.Helper()
tempDir, err := os.MkdirTemp("", "TestMigrate")
require.NoError(t, err)
t.Cleanup(func() {
os.RemoveAll(tempDir)
})
err = os.Chdir(tempDir)
require.NoError(t, err)
truncateTables(t)
}
setupSource := func(t *testing.T, source *Store) cleanUpFn {
t.Helper()
cfg := source.Get()
originalCfg := cfg.Clone()
cfg.ServiceSettings.SiteURL = model.NewString("http://example.com")
cfg.SamlSettings.IdpCertificateFile = &files[0]
cfg.SamlSettings.PublicCertificateFile = &files[1]
cfg.SamlSettings.PrivateKeyFile = &files[2]
cfg.PluginSettings.SignaturePublicKeyFiles = []string{
files[3],
files[4],
}
cfg.SqlSettings.DataSourceReplicas = []string{
"mysql://mmuser:password@tcp(replicahost:3306)/mattermost",
}
cfg.SqlSettings.DataSourceSearchReplicas = []string{
"mysql://mmuser:password@tcp(searchreplicahost:3306)/mattermost",
}
_, _, err := source.Set(cfg)
require.NoError(t, err)
for i, file := range files {
err = source.SetFile(file, []byte(filesData[i]))
require.NoError(t, err)
}
return func(store *Store) {
_, _, err := store.Set(originalCfg)
require.NoError(t, err)
}
}
assertDestination := func(t *testing.T, destination *Store, source *Store) {
t.Helper()
for i, file := range files {
hasFile, err := destination.HasFile(file)
require.NoError(t, err)
require.Truef(t, hasFile, "destination missing file %s", file)
actualData, err := destination.GetFile(file)
require.NoError(t, err)
assert.Equalf(t, []byte(filesData[i]), actualData, "destination has wrong contents for file %s", file)
}
assert.Equal(t, source.Get(), destination.Get())
}
t.Run("database to file", func(t *testing.T) {
setup(t)
pwd, err := os.Getwd()
require.NoError(t, err)
sqlSettings := mainHelper.GetSQLSettings()
destinationDSN := path.Join(pwd, "config-custom.json")
sourceDSN := getDsn(*sqlSettings.DriverName, *sqlSettings.DataSource)
sourcedb, err := NewDatabaseStore(sourceDSN)
require.NoError(t, err)
source, err := NewStoreFromBacking(sourcedb, nil, false)
require.NoError(t, err)
defer source.Close()
cleanUp := setupSource(t, source)
err = Migrate(sourceDSN, destinationDSN)
require.NoError(t, err)
destinationfile, err := NewFileStore(destinationDSN, false)
require.NoError(t, err)
destination, err := NewStoreFromBacking(destinationfile, nil, false)
require.NoError(t, err)
defer destination.Close()
defer cleanUp(destination)
assertDestination(t, destination, source)
})
t.Run("file to database", func(t *testing.T) {
setup(t)
pwd, err := os.Getwd()
require.NoError(t, err)
sqlSettings := mainHelper.GetSQLSettings()
sourceDSN := path.Join(pwd, "config-custom.json")
destinationDSN := getDsn(*sqlSettings.DriverName, *sqlSettings.DataSource)
sourcefile, err := NewFileStore(sourceDSN, true)
require.NoError(t, err)
source, err := NewStoreFromBacking(sourcefile, nil, false)
require.NoError(t, err)
defer source.Close()
cleanUp := setupSource(t, source)
err = Migrate(sourceDSN, destinationDSN)
require.NoError(t, err)
destinationdb, err := NewDatabaseStore(destinationDSN)
require.NoError(t, err)
destination, err := NewStoreFromBacking(destinationdb, nil, false)
require.NoError(t, err)
defer destination.Close()
defer cleanUp(destination)
assertDestination(t, destination, source)
})
}

Просмотреть файл

@@ -0,0 +1 @@
-- It is intentionally to not have a rollback for adding configurations table.

Просмотреть файл

@@ -0,0 +1,42 @@
CREATE TABLE IF NOT EXISTS Configurations (
Id VARCHAR(26) PRIMARY KEY,
Value TEXT NOT NULL,
CreateAt BIGINT NOT NULL,
Active BOOLEAN NULL UNIQUE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE table_name = 'Configurations'
AND table_schema = DATABASE()
AND column_name = 'Value'
AND NOT data_type = 'MEDIUMTEXT'
) > 0,
'ALTER TABLE Configurations MODIFY Value MEDIUMTEXT;',
'SELECT 1'
));
PREPARE alterIfExists FROM @preparedStatement;
EXECUTE alterIfExists;
DEALLOCATE PREPARE alterIfExists;
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES AS T
JOIN INFORMATION_SCHEMA.COLUMNS AS C USING (TABLE_SCHEMA, TABLE_NAME)
JOIN INFORMATION_SCHEMA.COLLATION_CHARACTER_SET_APPLICABILITY AS CCSA ON (T.TABLE_COLLATION = CCSA.COLLATION_NAME)
WHERE TABLE_SCHEMA = DATABASE()
AND C.DATA_TYPE IN ('enum', 'varchar', 'char', 'text', 'mediumtext', 'longtext')
AND TABLE_NAME = 'Configurations'
AND C.CHARACTER_SET_NAME != 'utf8mb4'
) > 0,
'ALTER TABLE Configurations CONVERT TO CHARACTER SET utf8mb4;',
'SELECT 1'
));
PREPARE alterIfExists FROM @preparedStatement;
EXECUTE alterIfExists;
DEALLOCATE PREPARE alterIfExists;

Просмотреть файл

@@ -0,0 +1 @@
-- It is intentionally to not have a rollback for adding configuration files table.

Просмотреть файл

@@ -0,0 +1,40 @@
CREATE TABLE IF NOT EXISTS ConfigurationFiles (
Name VARCHAR(64) PRIMARY KEY,
Data TEXT NOT NULL,
CreateAt BIGINT NOT NULL,
UpdateAt BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE table_name = 'ConfigurationFiles'
AND table_schema = DATABASE()
AND column_name = 'Data'
AND NOT data_type = 'MEDIUMTEXT'
) > 0,
'ALTER TABLE ConfigurationFiles MODIFY Data MEDIUMTEXT;',
'SELECT 1'
));
PREPARE alterIfExists FROM @preparedStatement;
EXECUTE alterIfExists;
DEALLOCATE PREPARE alterIfExists;
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES AS T
JOIN INFORMATION_SCHEMA.COLUMNS AS C USING (TABLE_SCHEMA, TABLE_NAME)
JOIN INFORMATION_SCHEMA.COLLATION_CHARACTER_SET_APPLICABILITY AS CCSA ON (T.TABLE_COLLATION = CCSA.COLLATION_NAME)
WHERE TABLE_SCHEMA = DATABASE()
AND C.DATA_TYPE IN ('enum', 'varchar', 'char', 'text', 'mediumtext', 'longtext')
AND TABLE_NAME = 'ConfigurationFiles'
AND C.CHARACTER_SET_NAME != 'utf8mb4'
) > 0,
'ALTER TABLE ConfigurationFiles CONVERT TO CHARACTER SET utf8mb4;',
'SELECT 1'
));
PREPARE alterIfExists FROM @preparedStatement;
EXECUTE alterIfExists;
DEALLOCATE PREPARE alterIfExists;

Просмотреть файл

@@ -0,0 +1,14 @@
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE table_name = 'Configurations'
AND table_schema = DATABASE()
AND column_name = 'SHA'
) > 0,
'ALTER TABLE Configurations DROP COLUMN SHA;',
'SELECT 1'
));
PREPARE alterIfExists FROM @preparedStatement;
EXECUTE alterIfExists;
DEALLOCATE PREPARE alterIfExists;

Просмотреть файл

@@ -0,0 +1,14 @@
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE table_name = 'Configurations'
AND table_schema = DATABASE()
AND column_name = 'SHA'
) > 0,
'SELECT 1',
'ALTER TABLE Configurations ADD COLUMN SHA char(64) DEFAULT "";'
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;

Просмотреть файл

@@ -0,0 +1 @@
-- It is intentionally to not have a rollback for adding configurations table.

Просмотреть файл

@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS configurations (
id varchar(26),
value text NOT NULL,
createat bigint NOT NULL,
active boolean DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE (active)
);

Просмотреть файл

@@ -0,0 +1 @@
-- It is intentionally to not have a rollback for adding configuration files table.

Просмотреть файл

@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS configurationfiles (
name VARCHAR(64),
data text NOT NULL,
createat bigint NOT NULL,
updateat bigint NOT NULL,
PRIMARY KEY (name)
);

Просмотреть файл

@@ -0,0 +1 @@
ALTER TABLE Configurations DROP COLUMN IF EXISTS SHA;

Просмотреть файл

@@ -0,0 +1 @@
ALTER TABLE Configurations ADD COLUMN IF NOT EXISTS SHA CHAR(64) DEFAULT '';

413
server/config/store.go Обычный файл
Просмотреть файл

@@ -0,0 +1,413 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package config
import (
"encoding/json"
"reflect"
"sync"
"time"
"github.com/pkg/errors"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/channels/utils/jsonutils"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/i18n"
)
var (
// ErrReadOnlyStore is returned when an attempt to modify a read-only
// configuration store is made.
ErrReadOnlyStore = errors.New("configuration store is read-only")
)
// Store is the higher level object that handles storing and retrieval of config data.
// To do so it relies on a variety of backing stores (e.g. file, database, memory).
type Store struct {
emitter
backingStore BackingStore
configLock sync.RWMutex
config *model.Config
configNoEnv *model.Config
configCustomDefaults *model.Config
readOnly bool
readOnlyFF bool
}
// BackingStore defines the behaviour exposed by the underlying store
// implementation (e.g. file, database).
type BackingStore interface {
// Set replaces the current configuration in its entirety and updates the backing store.
Set(*model.Config) error
// Load retrieves the configuration stored. If there is no configuration stored
// the io.ReadCloser will be nil
Load() ([]byte, error)
// GetFile fetches the contents of a previously persisted configuration file.
// If no such file exists, an empty byte array will be returned without error.
GetFile(name string) ([]byte, error)
// SetFile sets or replaces the contents of a configuration file.
SetFile(name string, data []byte) error
// HasFile returns true if the given file was previously persisted.
HasFile(name string) (bool, error)
// RemoveFile removes a previously persisted configuration file.
RemoveFile(name string) error
// String describes the backing store for the config.
String() string
// Close cleans up resources associated with the store.
Close() error
}
// NewStoreFromBacking creates and returns a new config store given a backing store.
func NewStoreFromBacking(backingStore BackingStore, customDefaults *model.Config, readOnly bool) (*Store, error) {
store := &Store{
backingStore: backingStore,
configCustomDefaults: customDefaults,
readOnly: readOnly,
readOnlyFF: true,
}
if err := store.Load(); err != nil {
return nil, errors.Wrap(err, "unable to load on store creation")
}
return store, nil
}
// NewStoreFromDSN creates and returns a new config store backed by either a database or file store
// depending on the value of the given data source name string.
func NewStoreFromDSN(dsn string, readOnly bool, customDefaults *model.Config, createFileIfNotExist bool) (*Store, error) {
var err error
var backingStore BackingStore
if IsDatabaseDSN(dsn) {
backingStore, err = NewDatabaseStore(dsn)
} else {
backingStore, err = NewFileStore(dsn, createFileIfNotExist)
}
if err != nil {
return nil, err
}
store, err := NewStoreFromBacking(backingStore, customDefaults, readOnly)
if err != nil {
backingStore.Close()
return nil, errors.Wrap(err, "failed to create store")
}
return store, nil
}
// NewTestMemoryStore returns a new config store backed by a memory store
// to be used for testing purposes.
func NewTestMemoryStore() *Store {
memoryStore, err := NewMemoryStore()
if err != nil {
panic("failed to initialize memory store: " + err.Error())
}
configStore, err := NewStoreFromBacking(memoryStore, nil, false)
if err != nil {
panic("failed to initialize config store: " + err.Error())
}
return configStore
}
// Get fetches the current, cached configuration.
func (s *Store) Get() *model.Config {
s.configLock.RLock()
defer s.configLock.RUnlock()
return s.config
}
// GetNoEnv fetches the current cached configuration without environment variable overrides.
func (s *Store) GetNoEnv() *model.Config {
s.configLock.RLock()
defer s.configLock.RUnlock()
return s.configNoEnv
}
// GetEnvironmentOverrides fetches the configuration fields overridden by environment variables.
func (s *Store) GetEnvironmentOverrides() map[string]any {
return generateEnvironmentMap(GetEnvironment(), nil)
}
// GetEnvironmentOverridesWithFilter fetches the configuration fields overridden by environment variables.
// If filter is not nil and returns false for a struct field, that field will be omitted.
func (s *Store) GetEnvironmentOverridesWithFilter(filter func(reflect.StructField) bool) map[string]any {
return generateEnvironmentMap(GetEnvironment(), filter)
}
// RemoveEnvironmentOverrides returns a new config without the environment
// overrides.
func (s *Store) RemoveEnvironmentOverrides(cfg *model.Config) *model.Config {
s.configLock.RLock()
defer s.configLock.RUnlock()
return removeEnvOverrides(cfg, s.configNoEnv, s.GetEnvironmentOverrides())
}
// SetReadOnlyFF sets whether feature flags should be written out to
// config or treated as read-only.
func (s *Store) SetReadOnlyFF(readOnly bool) {
s.configLock.Lock()
defer s.configLock.Unlock()
s.readOnlyFF = readOnly
}
// Set replaces the current configuration in its entirety and updates the backing store.
// It returns both old and new versions of the config.
func (s *Store) Set(newCfg *model.Config) (*model.Config, *model.Config, error) {
s.configLock.Lock()
defer s.configLock.Unlock()
if s.readOnly {
return nil, nil, ErrReadOnlyStore
}
newCfg = newCfg.Clone()
oldCfg := s.config.Clone()
oldCfgNoEnv := s.configNoEnv
// Setting defaults allows us to accept partial config objects.
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)
// We apply back environment overrides since the input config may or
// may not have them applied.
newCfg = applyEnvironmentMap(newCfg, GetEnvironment())
fixConfig(newCfg)
if err := newCfg.IsValid(); err != nil {
return nil, nil, errors.Wrap(err, "new configuration is invalid")
}
// We attempt to remove any environment override that may be present in the input config.
newCfgNoEnv := removeEnvOverrides(newCfg, oldCfgNoEnv, s.GetEnvironmentOverrides())
// Don't store feature flags unless we are on MM cloud
// MM cloud uses config in the DB as a cache of the feature flag
// settings in case the management system is down when a pod starts.
// Backing up feature flags section in case we need to restore them later on.
oldCfgFF := oldCfg.FeatureFlags
oldCfgNoEnvFF := oldCfgNoEnv.FeatureFlags
// Clearing FF sections to avoid both comparing and persisting them.
if s.readOnlyFF {
oldCfg.FeatureFlags = nil
newCfg.FeatureFlags = nil
newCfgNoEnv.FeatureFlags = nil
}
if err := s.backingStore.Set(newCfgNoEnv); err != nil {
return nil, nil, errors.Wrap(err, "failed to persist")
}
hasChanged, err := equal(oldCfg, newCfg)
if err != nil {
return nil, nil, errors.Wrap(err, "failed to compare configs")
}
// We restore the previously cleared feature flags sections back.
if s.readOnlyFF {
oldCfg.FeatureFlags = oldCfgFF
newCfg.FeatureFlags = oldCfgFF
newCfgNoEnv.FeatureFlags = oldCfgNoEnvFF
}
s.configNoEnv = newCfgNoEnv
s.config = newCfg
newCfgCopy := newCfg.Clone()
if hasChanged {
s.configLock.Unlock()
s.invokeConfigListeners(oldCfg, newCfgCopy.Clone())
s.configLock.Lock()
}
return oldCfg, newCfgCopy, nil
}
// Load updates the current configuration from the backing store, possibly initializing.
func (s *Store) Load() error {
s.configLock.Lock()
defer s.configLock.Unlock()
oldCfg := &model.Config{}
if s.config != nil {
oldCfg = s.config.Clone()
}
configBytes, err := s.backingStore.Load()
if err != nil {
return err
}
loadedCfg := &model.Config{}
if len(configBytes) != 0 {
if err = json.Unmarshal(configBytes, &loadedCfg); err != nil {
return jsonutils.HumanizeJSONError(err, configBytes)
}
}
// If we have custom defaults set, the initial config is merged on
// top of them and we delete them not to be used again in the
// configuration reloads
if s.configCustomDefaults != nil {
var mErr error
loadedCfg, mErr = Merge(s.configCustomDefaults, loadedCfg, nil)
if mErr != nil {
return errors.Wrap(mErr, "failed to merge custom config defaults")
}
s.configCustomDefaults = nil
}
// We set the SiteURL to empty (if nil) so that the following call to
// SetDefaults() will generate missing data. This avoids an additional write
// to the backing store.
if loadedCfg.ServiceSettings.SiteURL == nil {
loadedCfg.ServiceSettings.SiteURL = model.NewString("")
}
// Setting defaults allows us to accept partial config objects.
loadedCfg.SetDefaults()
// No need to clone here since the below call to applyEnvironmentMap
// already does that internally.
loadedCfgNoEnv := loadedCfg
fixConfig(loadedCfgNoEnv)
loadedCfg = applyEnvironmentMap(loadedCfg, GetEnvironment())
fixConfig(loadedCfg)
if appErr := loadedCfg.IsValid(); appErr != nil {
// Translating the error before displaying it in the console.
// Defaulting to english for server side language.
appErr.Translate(i18n.GetUserTranslations("en"))
return errors.Wrap(appErr, "invalid config")
}
// Backing up feature flags section in case we need to restore them later on.
oldCfgFF := oldCfg.FeatureFlags
loadedCfgFF := loadedCfg.FeatureFlags
loadedCfgNoEnvFF := loadedCfgNoEnv.FeatureFlags
// Clearing FF sections to avoid both comparing and persisting them.
if s.readOnlyFF {
oldCfg.FeatureFlags = nil
loadedCfg.FeatureFlags = nil
loadedCfgNoEnv.FeatureFlags = nil
}
// Check for changes that may have happened on load to the backing store.
hasChanged, err := equal(oldCfg, loadedCfg)
if err != nil {
return errors.Wrap(err, "failed to compare configs")
}
// We write back to the backing store only if the store is not read-only
// and the config has either changed or is missing.
if !s.readOnly && (hasChanged || len(configBytes) == 0) {
err := s.backingStore.Set(loadedCfgNoEnv)
if err != nil && !errors.Is(err, ErrReadOnlyConfiguration) {
return errors.Wrap(err, "failed to persist")
}
}
// We restore the previously cleared feature flags sections back.
if s.readOnlyFF {
oldCfg.FeatureFlags = oldCfgFF
loadedCfg.FeatureFlags = loadedCfgFF
loadedCfgNoEnv.FeatureFlags = loadedCfgNoEnvFF
}
s.config = loadedCfg
s.configNoEnv = loadedCfgNoEnv
loadedCfgCopy := loadedCfg.Clone()
if hasChanged {
s.configLock.Unlock()
s.invokeConfigListeners(oldCfg, loadedCfgCopy)
s.configLock.Lock()
}
return nil
}
// GetFile fetches the contents of a previously persisted configuration file.
// If no such file exists, an empty byte array will be returned without error.
func (s *Store) GetFile(name string) ([]byte, error) {
s.configLock.RLock()
defer s.configLock.RUnlock()
return s.backingStore.GetFile(name)
}
// SetFile sets or replaces the contents of a configuration file.
func (s *Store) SetFile(name string, data []byte) error {
s.configLock.Lock()
defer s.configLock.Unlock()
if s.readOnly {
return ErrReadOnlyStore
}
return s.backingStore.SetFile(name, data)
}
// HasFile returns true if the given file was previously persisted.
func (s *Store) HasFile(name string) (bool, error) {
s.configLock.RLock()
defer s.configLock.RUnlock()
return s.backingStore.HasFile(name)
}
// RemoveFile removes a previously persisted configuration file.
func (s *Store) RemoveFile(name string) error {
s.configLock.Lock()
defer s.configLock.Unlock()
if s.readOnly {
return ErrReadOnlyStore
}
return s.backingStore.RemoveFile(name)
}
// String describes the backing store for the config.
func (s *Store) String() string {
return s.backingStore.String()
}
// Close cleans up resources associated with the store.
func (s *Store) Close() error {
s.configLock.Lock()
defer s.configLock.Unlock()
return s.backingStore.Close()
}
// IsReadOnly returns whether or not the store is read-only.
func (s *Store) IsReadOnly() bool {
s.configLock.RLock()
defer s.configLock.RUnlock()
return s.readOnly
}
// Cleanup removes outdated configurations from the database.
// this is a no-op function for FileStore type backing store.
func (s *Store) CleanUp() error {
switch bs := s.backingStore.(type) {
case *DatabaseStore:
dur := time.Duration(*s.config.JobSettings.CleanupConfigThresholdDays) * time.Hour * 24
expiry := model.GetMillisForTime(time.Now().Add(-dur))
return bs.cleanUp(int(expiry))
default:
return nil
}
}

103
server/config/store_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,103 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package config
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestNewStoreFromDSN(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
sqlSettings := mainHelper.GetSQLSettings()
tempDir, err := os.MkdirTemp("", "TestNewStore")
require.NoError(t, err)
err = os.Chdir(tempDir)
require.NoError(t, err)
require.NoError(t, os.Mkdir(filepath.Join(tempDir, "config"), 0700))
t.Run("database dsn", func(t *testing.T) {
ds, err2 := NewStoreFromDSN(getDsn(*sqlSettings.DriverName, *sqlSettings.DataSource), false, nil, false)
require.NoError(t, err2)
ds.Close()
})
t.Run("file dsn", func(t *testing.T) {
defer os.Remove("config_test.json")
fs, err := NewStoreFromDSN("config_test.json", false, nil, true)
require.NoError(t, err)
fs.Close()
})
}
func TestNewStoreReadOnly(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
sqlSettings := mainHelper.GetSQLSettings()
tempDir, tErr := os.MkdirTemp("", "TestNewStore")
require.NoError(t, tErr)
tErr = os.Chdir(tempDir)
require.NoError(t, tErr)
require.NoError(t, os.Mkdir(filepath.Join(tempDir, "config"), 0700))
t.Run("database dsn", func(t *testing.T) {
ds, err := NewStoreFromDSN(getDsn(*sqlSettings.DriverName, *sqlSettings.DataSource), true, nil, false)
require.NoError(t, err)
t.Run("Set", func(t *testing.T) {
oldCfg, newCfg, err2 := ds.Set(emptyConfig)
require.Nil(t, oldCfg)
require.Nil(t, newCfg)
require.Equal(t, ErrReadOnlyStore, err2)
})
t.Run("SetFile", func(t *testing.T) {
err := ds.SetFile("config.json", []byte{})
require.Equal(t, ErrReadOnlyStore, err)
})
t.Run("RemoveFile", func(t *testing.T) {
err := ds.RemoveFile("config.json")
require.Equal(t, ErrReadOnlyStore, err)
})
ds.Close()
})
t.Run("file dsn", func(t *testing.T) {
fs, err := NewStoreFromDSN("config_test.json", true, nil, true)
require.NoError(t, err)
t.Run("Set", func(t *testing.T) {
oldCfg, newCfg, err := fs.Set(emptyConfig)
require.Nil(t, oldCfg)
require.Nil(t, newCfg)
require.Equal(t, ErrReadOnlyStore, err)
})
t.Run("SetFile", func(t *testing.T) {
err := fs.SetFile("config_test.json", []byte{})
require.Equal(t, ErrReadOnlyStore, err)
})
t.Run("RemoveFile", func(t *testing.T) {
err := fs.RemoveFile("config_test.json")
require.Equal(t, ErrReadOnlyStore, err)
})
fs.Close()
})
}

263
server/config/utils.go Обычный файл
Просмотреть файл

@@ -0,0 +1,263 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package config
import (
"bytes"
"encoding/json"
"fmt"
"reflect"
"strings"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/channels/utils"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/i18n"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
// marshalConfig converts the given configuration into JSON bytes for persistence.
func marshalConfig(cfg *model.Config) ([]byte, error) {
return json.MarshalIndent(cfg, "", " ")
}
// desanitize replaces fake settings with their actual values.
func desanitize(actual, target *model.Config) {
if target.LdapSettings.BindPassword != nil && *target.LdapSettings.BindPassword == model.FakeSetting {
*target.LdapSettings.BindPassword = *actual.LdapSettings.BindPassword
}
if *target.FileSettings.PublicLinkSalt == model.FakeSetting {
*target.FileSettings.PublicLinkSalt = *actual.FileSettings.PublicLinkSalt
}
if *target.FileSettings.AmazonS3SecretAccessKey == model.FakeSetting {
target.FileSettings.AmazonS3SecretAccessKey = actual.FileSettings.AmazonS3SecretAccessKey
}
if *target.EmailSettings.SMTPPassword == model.FakeSetting {
target.EmailSettings.SMTPPassword = actual.EmailSettings.SMTPPassword
}
if *target.GitLabSettings.Secret == model.FakeSetting {
target.GitLabSettings.Secret = actual.GitLabSettings.Secret
}
if target.GoogleSettings.Secret != nil && *target.GoogleSettings.Secret == model.FakeSetting {
target.GoogleSettings.Secret = actual.GoogleSettings.Secret
}
if target.Office365Settings.Secret != nil && *target.Office365Settings.Secret == model.FakeSetting {
target.Office365Settings.Secret = actual.Office365Settings.Secret
}
if target.OpenIdSettings.Secret != nil && *target.OpenIdSettings.Secret == model.FakeSetting {
target.OpenIdSettings.Secret = actual.OpenIdSettings.Secret
}
if *target.SqlSettings.DataSource == model.FakeSetting {
*target.SqlSettings.DataSource = *actual.SqlSettings.DataSource
}
if *target.SqlSettings.AtRestEncryptKey == model.FakeSetting {
target.SqlSettings.AtRestEncryptKey = actual.SqlSettings.AtRestEncryptKey
}
if *target.ElasticsearchSettings.Password == model.FakeSetting {
*target.ElasticsearchSettings.Password = *actual.ElasticsearchSettings.Password
}
if len(target.SqlSettings.DataSourceReplicas) == len(actual.SqlSettings.DataSourceReplicas) {
for i, value := range target.SqlSettings.DataSourceReplicas {
if value == model.FakeSetting {
target.SqlSettings.DataSourceReplicas[i] = actual.SqlSettings.DataSourceReplicas[i]
}
}
}
if len(target.SqlSettings.DataSourceSearchReplicas) == len(actual.SqlSettings.DataSourceSearchReplicas) {
for i, value := range target.SqlSettings.DataSourceSearchReplicas {
if value == model.FakeSetting {
target.SqlSettings.DataSourceSearchReplicas[i] = actual.SqlSettings.DataSourceSearchReplicas[i]
}
}
}
if *target.MessageExportSettings.GlobalRelaySettings.SMTPPassword == model.FakeSetting {
*target.MessageExportSettings.GlobalRelaySettings.SMTPPassword = *actual.MessageExportSettings.GlobalRelaySettings.SMTPPassword
}
if target.ServiceSettings.GfycatAPISecret != nil && *target.ServiceSettings.GfycatAPISecret == model.FakeSetting {
*target.ServiceSettings.GfycatAPISecret = *actual.ServiceSettings.GfycatAPISecret
}
if *target.ServiceSettings.SplitKey == model.FakeSetting {
*target.ServiceSettings.SplitKey = *actual.ServiceSettings.SplitKey
}
}
// fixConfig patches invalid or missing data in the configuration.
func fixConfig(cfg *model.Config) {
// Ensure SiteURL has no trailing slash.
if strings.HasSuffix(*cfg.ServiceSettings.SiteURL, "/") {
*cfg.ServiceSettings.SiteURL = strings.TrimRight(*cfg.ServiceSettings.SiteURL, "/")
}
// Ensure the directory for a local file store has a trailing slash.
if *cfg.FileSettings.DriverName == model.ImageDriverLocal {
if *cfg.FileSettings.Directory != "" && !strings.HasSuffix(*cfg.FileSettings.Directory, "/") {
*cfg.FileSettings.Directory += "/"
}
}
FixInvalidLocales(cfg)
}
// 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 := i18n.GetSupportedLocales()
if _, ok := locales[*cfg.LocalizationSettings.DefaultServerLocale]; !ok {
*cfg.LocalizationSettings.DefaultServerLocale = model.DefaultLocale
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.DefaultLocale
mlog.Warn("DefaultClientLocale must be one of the supported locales. Setting DefaultClientLocale to en as default value.")
changed = true
}
if *cfg.LocalizationSettings.AvailableLocales != "" {
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
}
// Merge merges two configs together. The receiver's values are overwritten with the patch's
// values except when the patch's values are nil.
func Merge(cfg *model.Config, patch *model.Config, mergeConfig *utils.MergeConfig) (*model.Config, error) {
ret, err := utils.Merge(cfg, patch, mergeConfig)
if err != nil {
return nil, err
}
retCfg := ret.(model.Config)
return &retCfg, nil
}
func IsDatabaseDSN(dsn string) bool {
return strings.HasPrefix(dsn, "mysql://") ||
strings.HasPrefix(dsn, "postgres://") ||
strings.HasPrefix(dsn, "postgresql://")
}
// stripPassword remove the password from a given DSN
func stripPassword(dsn, schema string) string {
prefix := schema + "://"
dsn = strings.TrimPrefix(dsn, prefix)
i := strings.Index(dsn, ":")
j := strings.LastIndex(dsn, "@")
// Return error if no @ sign is found
if j < 0 {
return "(omitted due to error parsing the DSN)"
}
// Return back the input if no password is found
if i < 0 || i > j {
return prefix + dsn
}
return prefix + dsn[:i+1] + dsn[j:]
}
func isJSONMap(data string) bool {
var m map[string]any
return json.Unmarshal([]byte(data), &m) == nil
}
func GetValueByPath(path []string, obj any) (any, bool) {
r := reflect.ValueOf(obj)
var val reflect.Value
if r.Kind() == reflect.Map {
val = r.MapIndex(reflect.ValueOf(path[0]))
if val.IsValid() {
val = val.Elem()
}
} else {
val = r.FieldByName(path[0])
}
if !val.IsValid() {
return nil, false
}
switch {
case len(path) == 1:
return val.Interface(), true
case val.Kind() == reflect.Struct:
return GetValueByPath(path[1:], val.Interface())
case val.Kind() == reflect.Map:
remainingPath := strings.Join(path[1:], ".")
mapIter := val.MapRange()
for mapIter.Next() {
key := mapIter.Key().String()
if strings.HasPrefix(remainingPath, key) {
i := strings.Count(key, ".") + 2 // number of dots + a dot on each side
mapVal := mapIter.Value()
// if no sub field path specified, return the object
if len(path[i:]) == 0 {
return mapVal.Interface(), true
}
data := mapVal.Interface()
if mapVal.Kind() == reflect.Ptr {
data = mapVal.Elem().Interface() // if value is a pointer, dereference it
}
// pass subpath
return GetValueByPath(path[i:], data)
}
}
}
return nil, false
}
func equal(oldCfg, newCfg *model.Config) (bool, error) {
oldCfgBytes, err := json.Marshal(oldCfg)
if err != nil {
return false, fmt.Errorf("failed to marshal old config: %w", err)
}
newCfgBytes, err := json.Marshal(newCfg)
if err != nil {
return false, fmt.Errorf("failed to marshal new config: %w", err)
}
return !bytes.Equal(oldCfgBytes, newCfgBytes), nil
}

309
server/config/utils_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,309 @@
// 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/stretchr/testify/require"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/channels/utils"
)
func TestDesanitize(t *testing.T) {
actual := &model.Config{}
actual.SetDefaults()
// These setting should be ignored
actual.LdapSettings.Enable = model.NewBool(false)
actual.FileSettings.DriverName = model.NewString("s3")
// These settings should be desanitized into target.
actual.LdapSettings.BindPassword = model.NewString("bind_password")
actual.FileSettings.PublicLinkSalt = model.NewString("public_link_salt")
actual.FileSettings.AmazonS3SecretAccessKey = model.NewString("amazon_s3_secret_access_key")
actual.EmailSettings.SMTPPassword = model.NewString("smtp_password")
actual.GitLabSettings.Secret = model.NewString("secret")
actual.OpenIdSettings.Secret = model.NewString("secret")
actual.SqlSettings.DataSource = model.NewString("data_source")
actual.SqlSettings.AtRestEncryptKey = model.NewString("at_rest_encrypt_key")
actual.ElasticsearchSettings.Password = model.NewString("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 = model.NewBool(true)
target.FileSettings.DriverName = model.NewString("file")
// These settings should be updated from actual
target.LdapSettings.BindPassword = model.NewString(model.FakeSetting)
target.FileSettings.PublicLinkSalt = model.NewString(model.FakeSetting)
target.FileSettings.AmazonS3SecretAccessKey = model.NewString(model.FakeSetting)
target.EmailSettings.SMTPPassword = model.NewString(model.FakeSetting)
target.GitLabSettings.Secret = model.NewString(model.FakeSetting)
target.OpenIdSettings.Secret = model.NewString(model.FakeSetting)
target.SqlSettings.DataSource = model.NewString(model.FakeSetting)
target.SqlSettings.AtRestEncryptKey = model.NewString(model.FakeSetting)
target.ElasticsearchSettings.Password = model.NewString(model.FakeSetting)
target.SqlSettings.DataSourceReplicas = []string{model.FakeSetting, model.FakeSetting}
target.SqlSettings.DataSourceSearchReplicas = []string{model.FakeSetting, model.FakeSetting}
actualClone := actual.Clone()
desanitize(actual, target)
assert.Equal(t, actualClone, 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.SMTPPassword, *target.EmailSettings.SMTPPassword)
assert.Equal(t, *actual.GitLabSettings.Secret, *target.GitLabSettings.Secret)
assert.Equal(t, *actual.OpenIdSettings.Secret, *target.OpenIdSettings.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)
assert.Equal(t, actual.ServiceSettings.SplitKey, target.ServiceSettings.SplitKey)
}
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 TestIsDatabaseDSN(t *testing.T) {
testCases := []struct {
Name string
DSN string
Expected bool
}{
{
Name: "Mysql DSN",
DSN: "mysql://localhost",
Expected: true,
},
{
Name: "Postgresql 'postgres' DSN",
DSN: "postgres://localhost",
Expected: true,
},
{
Name: "Postgresql 'postgresql' DSN",
DSN: "postgresql://localhost",
Expected: true,
},
{
Name: "Empty DSN",
DSN: "",
Expected: false,
},
{
Name: "Default file DSN",
DSN: "config.json",
Expected: false,
},
{
Name: "Relative path DSN",
DSN: "configuration/config.json",
Expected: false,
},
{
Name: "Absolute path DSN",
DSN: "/opt/mattermost/configuration/config.json",
Expected: false,
},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
assert.Equal(t, tc.Expected, IsDatabaseDSN(tc.DSN))
})
}
}
func TestStripPassword(t *testing.T) {
for name, test := range map[string]struct {
DSN string
Schema string
ExpectedOut string
}{
"mysql": {
DSN: "mysql://mmuser:password@tcp(localhost:3306)/mattermost?charset=utf8mb4,utf8&readTimeout=30s",
Schema: "mysql",
ExpectedOut: "mysql://mmuser:@tcp(localhost:3306)/mattermost?charset=utf8mb4,utf8&readTimeout=30s",
},
"mysql idempotent": {
DSN: "mysql://mmuser:@tcp(localhost:3306)/mattermost?charset=utf8mb4,utf8&readTimeout=30s",
Schema: "mysql",
ExpectedOut: "mysql://mmuser:@tcp(localhost:3306)/mattermost?charset=utf8mb4,utf8&readTimeout=30s",
},
"mysql: password with : and @": {
DSN: "mysql://mmuser:p:assw@ord@tcp(localhost:3306)/mattermost?charset=utf8mb4,utf8&readTimeout=30s",
Schema: "mysql",
ExpectedOut: "mysql://mmuser:@tcp(localhost:3306)/mattermost?charset=utf8mb4,utf8&readTimeout=30s",
},
"mysql: password with @ and :": {
DSN: "mysql://mmuser:pa@sswo:rd@tcp(localhost:3306)/mattermost?charset=utf8mb4,utf8&readTimeout=30s",
Schema: "mysql",
ExpectedOut: "mysql://mmuser:@tcp(localhost:3306)/mattermost?charset=utf8mb4,utf8&readTimeout=30s",
},
"postgres": {
DSN: "postgres://mmuser:password@localhost:5432/mattermost?sslmode=disable&connect_timeout=10",
Schema: "postgres",
ExpectedOut: "postgres://mmuser:@localhost:5432/mattermost?sslmode=disable&connect_timeout=10",
},
"pipe": {
DSN: "mysql://user@unix(/path/to/socket)/dbname",
Schema: "mysql",
ExpectedOut: "mysql://user@unix(/path/to/socket)/dbname",
},
"malformed without :": {
DSN: "postgres://mmuserpassword@localhost:5432/mattermost?sslmode=disable&connect_timeout=10",
Schema: "postgres",
ExpectedOut: "postgres://mmuserpassword@localhost:5432/mattermost?sslmode=disable&connect_timeout=10",
},
"malformed without @": {
DSN: "postgres://mmuser:passwordlocalhost:5432/mattermost?sslmode=disable&connect_timeout=10",
Schema: "postgres",
ExpectedOut: "(omitted due to error parsing the DSN)",
},
} {
t.Run(name, func(t *testing.T) {
out := stripPassword(test.DSN, test.Schema)
assert.Equal(t, test.ExpectedOut, out)
})
}
}
func TestIsJSONMap(t *testing.T) {
tests := []struct {
name string
data string
want bool
}{
{name: "good json", data: `{"local_tcp": {
"Type": "tcp","Format": "json","Levels": [
{"ID": 5,"Name": "debug","Stacktrace": false}
],
"Options": {"ip": "localhost","port": 18065},
"MaxQueueSize": 1000}}
`, want: true,
},
{name: "empty json", data: "{}", want: true},
{name: "string json", data: `"test"`, want: false},
{name: "array json", data: `["test1", "test2"]`, want: false},
{name: "bad json", data: `{huh?}`, want: false},
{name: "filename", data: "/tmp/logger.conf", want: false},
{name: "mysql dsn", data: "mysql://mmuser:@tcp(localhost:3306)/mattermost?charset=utf8mb4,utf8&readTimeout=30s", want: false},
{name: "postgres dsn", data: "postgres://mmuser:passwordlocalhost:5432/mattermost?sslmode=disable&connect_timeout=10", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isJSONMap(tt.data); got != tt.want {
t.Errorf("isJSONMap() = %v, want %v", got, tt.want)
}
})
}
}
func TestEqual(t *testing.T) {
t.Run("nil", func(t *testing.T) {
diff, err := equal(nil, nil)
require.NoError(t, err)
require.False(t, diff)
})
t.Run("no diff", func(t *testing.T) {
old := minimalConfig.Clone()
new := minimalConfig.Clone()
diff, err := equal(old, new)
require.NoError(t, err)
require.False(t, diff)
})
t.Run("diff", func(t *testing.T) {
old := minimalConfig.Clone()
new := minimalConfig.Clone()
new.SqlSettings = model.SqlSettings{}
diff, err := equal(old, new)
require.NoError(t, err)
require.True(t, diff)
})
}