diff --git a/api4/system.go b/api4/system.go index b448d0d262..708b3f186c 100644 --- a/api4/system.go +++ b/api4/system.go @@ -4,11 +4,13 @@ package api4 import ( + "bytes" "encoding/json" "fmt" "io" "io/ioutil" "net/http" + "path" "runtime" "strconv" "time" @@ -64,6 +66,64 @@ func (api *API) InitSystem() { api.BaseRoutes.ApiRoot.Handle("/warn_metrics/trial-license-ack/{warn_metric_id:[A-Za-z0-9-_]+}", api.ApiHandler(requestTrialLicenseAndAckWarnMetric)).Methods("POST") api.BaseRoutes.System.Handle("/notices/{team_id:[A-Za-z0-9]+}", api.ApiSessionRequired(getProductNotices)).Methods("GET") api.BaseRoutes.System.Handle("/notices/view", api.ApiSessionRequired(updateViewedProductNotices)).Methods("PUT") + + api.BaseRoutes.System.Handle("/support_packet", api.ApiSessionRequired(generateSupportPacket)).Methods("GET") +} + +func generateSupportPacket(c *Context, w http.ResponseWriter, r *http.Request) { + const FileMime = "application/zip" + const OutputDirectory = "support_packet" + + // Checking to see if the user is a admin of any sort or not + // If they are a admin, they should theoritcally have access to one or more of the system console read permissions + if !c.App.SessionHasPermissionToAny(*c.App.Session(), model.SysconsoleReadPermissions) { + c.SetPermissionError(model.SysconsoleReadPermissions...) + return + } + + // Checking to see if the server has a e10 or e20 license (this feature is only permitted for servers with licenses) + if c.App.Srv().License() == nil { + c.Err = model.NewAppError("Api4.generateSupportPacket", "api.no_license", nil, "", http.StatusForbidden) + return + } + + fileDatas := c.App.GenerateSupportPacket() + + // Constructing the ZIP file name as per spec (mattermost_support_packet_YYYY-MM-DD-HH-MM.zip) + now := time.Now() + outputZipFilename := fmt.Sprintf("mattermost_support_packet_%s.zip", now.Format("2006-01-02-03-04")) + + fileStorageBackend, fileBackendErr := c.App.FileBackend() + if fileBackendErr != nil { + c.Err = fileBackendErr + return + } + + // We do this incase we get concurrent requests, we will always have a unique directory. + // This is to avoid the situation where we try to write to the same directory while we are trying to delete it (further down) + outputDirectoryToUse := OutputDirectory + "_" + model.NewId() + err := c.App.CreateZipFileAndAddFiles(fileStorageBackend, fileDatas, outputZipFilename, outputDirectoryToUse) + if err != nil { + c.Err = model.NewAppError("Api4.generateSupportPacket", "api.unable_to_create_zip_file", nil, err.Error(), http.StatusForbidden) + return + } + + fileBytes, err := fileStorageBackend.ReadFile(path.Join(outputDirectoryToUse, outputZipFilename)) + defer fileStorageBackend.RemoveDirectory(outputDirectoryToUse) + if err != nil { + c.Err = model.NewAppError("Api4.generateSupportPacket", "api.unable_to_read_file_from_backend", nil, err.Error(), http.StatusForbidden) + return + } + fileBytesReader := bytes.NewReader(fileBytes) + + // Send the zip file back to client + // We are able to pass 0 for content size due to the fact that Golang's serveContent (https://golang.org/src/net/http/fs.go) + // already sets that for us + writeFileResponseErr := writeFileResponse(outputZipFilename, FileMime, 0, now, *c.App.Config().ServiceSettings.WebserverMode, fileBytesReader, true, w, r) + if writeFileResponseErr != nil { + c.Err = model.NewAppError("generateSupportPacket", "api.unable_write_file_response", nil, writeFileResponseErr.Error(), http.StatusForbidden) + return + } } func getSystemPing(c *Context, w http.ResponseWriter, r *http.Request) { diff --git a/api4/system_test.go b/api4/system_test.go index 2515d4e08c..1a2977095e 100644 --- a/api4/system_test.go +++ b/api4/system_test.go @@ -187,6 +187,34 @@ func TestEmailTest(t *testing.T) { }) } +func TestGenerateSupportPacket(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + t.Run("As a System Administrator", func(t *testing.T) { + l := model.NewTestLicense() + th.App.Srv().SetLicense(l) + + file, resp := th.SystemAdminClient.GenerateSupportPacket() + require.Nil(t, resp.Error) + require.NotZero(t, len(file)) + }) + + t.Run("As a Regular User", func(t *testing.T) { + _, resp := th.Client.GenerateSupportPacket() + CheckForbiddenStatus(t, resp) + }) + + t.Run("Server with no License", func(t *testing.T) { + ok, resp := th.SystemAdminClient.RemoveLicenseFile() + CheckNoError(t, resp) + require.True(t, ok) + + _, resp = th.SystemAdminClient.GenerateSupportPacket() + CheckForbiddenStatus(t, resp) + }) +} + func TestSiteURLTest(t *testing.T) { th := Setup(t) defer th.TearDown() diff --git a/app/app_iface.go b/app/app_iface.go index f6573e6db3..8240f616a0 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -308,6 +308,9 @@ type AppIface interface { TeamMembersMinusGroupMembers(teamID string, groupIDs []string, page, perPage int) ([]*model.UserWithGroups, int64, *model.AppError) // This function migrates the default built in roles from code/config to the database. DoAdvancedPermissionsMigration() + // This function zip's up all the files in fileDatas array and then saves it to the directory specified with the specified zip file name + // Ensure the zip file name ends with a .zip + CreateZipFileAndAddFiles(fileBackend filesstore.FileBackend, fileDatas []model.FileData, zipFileName, directory string) error // This to be used for places we check the users password when they are already logged in DoubleCheckPassword(user *model.User, password string) *model.AppError // UpdateBotActive marks a bot as active or inactive, along with its corresponding user. @@ -506,6 +509,7 @@ type AppIface interface { FindTeamByName(name string) bool GenerateMfaSecret(userId string) (*model.MfaSecret, *model.AppError) GeneratePublicLink(siteURL string, info *model.FileInfo) string + GenerateSupportPacket() []model.FileData GetActivePluginManifests() ([]*model.Manifest, *model.AppError) GetAllChannels(page, perPage int, opts model.ChannelSearchOpts) (*model.ChannelListWithTeamData, *model.AppError) GetAllChannelsCount(opts model.ChannelSearchOpts) (int64, *model.AppError) diff --git a/app/file.go b/app/file.go index 2ebf66b7e0..c7c55d0dab 100644 --- a/app/file.go +++ b/app/file.go @@ -4,6 +4,7 @@ package app import ( + "archive/zip" "bytes" "crypto/sha256" "encoding/base64" @@ -18,6 +19,8 @@ import ( "mime/multipart" "net/http" "net/url" + "os" + "path" "path/filepath" "regexp" "strings" @@ -1258,3 +1261,49 @@ func (a *App) CopyFileInfos(userId string, fileIds []string) ([]string, *model.A return newFileIds, nil } + +// This function zip's up all the files in fileDatas array and then saves it to the directory specified with the specified zip file name +// Ensure the zip file name ends with a .zip +func (a *App) CreateZipFileAndAddFiles(fileBackend filesstore.FileBackend, fileDatas []model.FileData, zipFileName, directory string) error { + // Create Zip File (temporarily stored on disk) + conglomerateZipFile, err := os.Create(zipFileName) + if err != nil { + return err + } + defer os.Remove(zipFileName) + + // Create a new zip archive. + zipFileWriter := zip.NewWriter(conglomerateZipFile) + + // Populate Zip file with File Datas array + err = populateZipfile(zipFileWriter, fileDatas) + if err != nil { + return err + } + + conglomerateZipFile.Seek(0, 0) + _, err = fileBackend.WriteFile(conglomerateZipFile, path.Join(directory, zipFileName)) + if err != nil { + return err + } + + return nil +} + +// This is a implementation of Go's example of writing files to zip (with slight modification) +// https://golang.org/src/archive/zip/example_test.go +func populateZipfile(w *zip.Writer, fileDatas []model.FileData) error { + defer w.Close() + for _, fd := range fileDatas { + f, err := w.Create(fd.Filename) + if err != nil { + return err + } + + _, err = f.Write(fd.Body) + if err != nil { + return err + } + } + return nil +} diff --git a/app/file_test.go b/app/file_test.go index 463254e500..da735007cf 100644 --- a/app/file_test.go +++ b/app/file_test.go @@ -4,6 +4,7 @@ package app import ( + "errors" "fmt" "os" "path/filepath" @@ -14,6 +15,8 @@ import ( "github.com/stretchr/testify/require" "github.com/mattermost/mattermost-server/v5/model" + "github.com/mattermost/mattermost-server/v5/plugin/plugintest/mock" + "github.com/mattermost/mattermost-server/v5/services/filesstore/mocks" "github.com/mattermost/mattermost-server/v5/utils/fileutils" ) @@ -269,6 +272,23 @@ func TestMigrateFilenamesToFileInfos(t *testing.T) { assert.Equal(t, 0, len(infos)) } +func TestCreateZipFileAndAddFiles(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + mockBackend := mocks.FileBackend{} + mockBackend.On("WriteFile", mock.Anything, "directory-to-heaven/zip-file-name-to-heaven.zip").Return(int64(666), errors.New("Only those who dare to fail greatly can ever achieve greatly")) + + err := th.App.CreateZipFileAndAddFiles(&mockBackend, []model.FileData{}, "zip-file-name-to-heaven.zip", "directory-to-heaven") + require.NotNil(t, err) + require.Equal(t, err.Error(), "Only those who dare to fail greatly can ever achieve greatly") + + mockBackend = mocks.FileBackend{} + mockBackend.On("WriteFile", mock.Anything, "directory-to-heaven/zip-file-name-to-heaven.zip").Return(int64(666), nil) + err = th.App.CreateZipFileAndAddFiles(&mockBackend, []model.FileData{}, "zip-file-name-to-heaven.zip", "directory-to-heaven") + require.Nil(t, err) +} + func TestCopyFileInfos(t *testing.T) { th := Setup(t) defer th.TearDown() diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 4dd0960c9f..32a9f70913 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -2416,6 +2416,28 @@ func (a *OpenTracingAppLayer) CreateWebhookPost(userId string, channel *model.Ch return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) CreateZipFileAndAddFiles(fileBackend filesstore.FileBackend, fileDatas []model.FileData, zipFileName string, directory string) error { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CreateZipFileAndAddFiles") + + a.ctx = newCtx + a.app.Srv().Store.SetContext(newCtx) + defer func() { + a.app.Srv().Store.SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0 := a.app.CreateZipFileAndAddFiles(fileBackend, fileDatas, zipFileName, directory) + + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + func (a *OpenTracingAppLayer) DBHealthCheckDelete() error { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DBHealthCheckDelete") @@ -3943,6 +3965,23 @@ func (a *OpenTracingAppLayer) GeneratePublicLink(siteURL string, info *model.Fil return resultVar0 } +func (a *OpenTracingAppLayer) GenerateSupportPacket() []model.FileData { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GenerateSupportPacket") + + a.ctx = newCtx + a.app.Srv().Store.SetContext(newCtx) + defer func() { + a.app.Srv().Store.SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0 := a.app.GenerateSupportPacket() + + return resultVar0 +} + func (a *OpenTracingAppLayer) GetActivePluginManifests() ([]*model.Manifest, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetActivePluginManifests") diff --git a/app/server.go b/app/server.go index e5afca8031..10c37d1b27 100644 --- a/app/server.go +++ b/app/server.go @@ -6,8 +6,10 @@ package app import ( "context" "crypto/tls" + "encoding/json" "fmt" "hash/maphash" + "io/ioutil" "net" "net/http" "net/url" @@ -23,6 +25,8 @@ import ( "syscall" "time" + "gopkg.in/yaml.v2" + "github.com/getsentry/sentry-go" sentryhttp "github.com/getsentry/sentry-go/http" "github.com/gorilla/mux" @@ -636,6 +640,12 @@ func (s *Server) AppOptions() []AppOption { } } +// Return Database type (postgres or mysql) and current version of Mattermost +func (s *Server) DatabaseTypeAndMattermostVersion() (string, string) { + mattermostVersion, _ := s.Store.System().GetByName("Version") + return *s.Config().SqlSettings.DriverName, mattermostVersion.Value +} + // initLogging initializes and configures the logger. This may be called more than once. func (s *Server) initLogging() error { if s.Log == nil { @@ -1611,3 +1621,178 @@ func (s *Server) HttpService() httpservice.HTTPService { func (s *Server) SetLog(l *mlog.Logger) { s.Log = l } + +func (a *App) GenerateSupportPacket() []model.FileData { + // If any errors we come across within this function, we will log it in a warning.txt file so that we know why certain files did not get produced if any + var warnings []string + + // Creating an array of files that we are going to be adding to our zip file + fileDatas := []model.FileData{} + + // A array of the functions that we can iterate through since they all have the same return value + functions := []func() (*model.FileData, string){ + a.generateSupportPacketYaml, + a.createPluginsFile, + a.createSanitizedConfigFile, + a.getMattermostLog, + a.getNotificationsLog, + } + + for _, fn := range functions { + fileData, warning := fn() + + if fileData != nil { + fileDatas = append(fileDatas, *fileData) + } else { + warnings = append(warnings, warning) + } + } + + // Adding a warning.txt file to the fileDatas if any warning + if len(warnings) > 0 { + finalWarning := strings.Join(warnings, "\n") + fileDatas = append(fileDatas, model.FileData{ + Filename: "warning.txt", + Body: []byte(finalWarning), + }) + } + + return fileDatas +} + +func (a *App) getNotificationsLog() (*model.FileData, string) { + var warning string + + // Getting notifications.log + if *a.Srv().Config().NotificationLogSettings.EnableFile { + // notifications.log + notificationsLog := utils.GetNotificationsLogFileLocation(*a.Srv().Config().LogSettings.FileLocation) + + notificationsLogFileData, notificationsLogFileDataErr := ioutil.ReadFile(notificationsLog) + + if notificationsLogFileDataErr == nil { + fileData := model.FileData{ + Filename: "notifications.log", + Body: notificationsLogFileData, + } + return &fileData, "" + } + + warning = fmt.Sprintf("ioutil.ReadFile(notificationsLog) Error: %s", notificationsLogFileDataErr.Error()) + + } else { + warning = "Unable to retrieve notifications.log because LogSettings: EnableFile is false in config.json" + } + + return nil, warning +} + +func (a *App) getMattermostLog() (*model.FileData, string) { + var warning string + + // Getting mattermost.log + if *a.Srv().Config().LogSettings.EnableFile { + // mattermost.log + mattermostLog := utils.GetLogFileLocation(*a.Srv().Config().LogSettings.FileLocation) + + mattermostLogFileData, mattermostLogFileDataErr := ioutil.ReadFile(mattermostLog) + + if mattermostLogFileDataErr == nil { + fileData := model.FileData{ + Filename: "mattermost.log", + Body: mattermostLogFileData, + } + return &fileData, "" + } + warning = fmt.Sprintf("ioutil.ReadFile(mattermostLog) Error: %s", mattermostLogFileDataErr.Error()) + + } else { + warning = "Unable to retrieve mattermost.log because LogSettings: EnableFile is false in config.json" + } + + return nil, warning +} + +func (a *App) createSanitizedConfigFile() (*model.FileData, string) { + // Getting sanitized config, prettifying it, and then adding it to our file data array + sanitizedConfigPrettyJSON, err := json.MarshalIndent(a.GetSanitizedConfig(), "", " ") + if err == nil { + fileData := model.FileData{ + Filename: "sanitized_config.json", + Body: sanitizedConfigPrettyJSON, + } + return &fileData, "" + } + + warning := fmt.Sprintf("json.MarshalIndent(c.App.GetSanitizedConfig()) Error: %s", err.Error()) + return nil, warning +} + +func (a *App) createPluginsFile() (*model.FileData, string) { + var warning string + + // Getting the plugins installed on the server, prettify it, and then add them to the file data array + pluginsResponse, appErr := a.GetPlugins() + if appErr == nil { + pluginsPrettyJSON, err := json.MarshalIndent(pluginsResponse, "", " ") + if err == nil { + fileData := model.FileData{ + Filename: "plugins.json", + Body: pluginsPrettyJSON, + } + + return &fileData, "" + } + + warning = fmt.Sprintf("json.MarshalIndent(pluginsResponse) Error: %s", err.Error()) + } else { + warning = fmt.Sprintf("c.App.GetPlugins() Error: %s", appErr.Error()) + } + + return nil, warning +} + +func (a *App) generateSupportPacketYaml() (*model.FileData, string) { + // Here we are getting information regarding Elastic Search + var elasticServerVersion string + var elasticServerPlugins []string + if a.Srv().SearchEngine.ElasticsearchEngine != nil { + elasticServerVersion = a.Srv().SearchEngine.ElasticsearchEngine.GetFullVersion() + elasticServerPlugins = a.Srv().SearchEngine.ElasticsearchEngine.GetPlugins() + } + + // Here we are getting information regarding LDAP + ldapInterface := a.Srv().Ldap + var vendorName, vendorVersion string + if ldapInterface != nil { + vendorName, vendorVersion = ldapInterface.GetVendorNameAndVendorVersion() + } + + // Here we are getting information regarding the database (mysql/postgres + current Mattermost version) + databaseType, databaseVersion := a.Srv().DatabaseTypeAndMattermostVersion() + + // Creating the struct for support packet yaml file + supportPacket := model.SupportPacket{ + ServerOS: runtime.GOOS, + ServerArchitecture: runtime.GOARCH, + DatabaseType: databaseType, + DatabaseVersion: databaseVersion, + LdapVendorName: vendorName, + LdapVendorVersion: vendorVersion, + ElasticServerVersion: elasticServerVersion, + ElasticServerPlugins: elasticServerPlugins, + } + + // Marshal to a Yaml File + supportPacketYaml, err := yaml.Marshal(&supportPacket) + if err == nil { + fileData := model.FileData{ + Filename: "support_packet.yaml", + Body: supportPacketYaml, + } + return &fileData, "" + } + + warning := fmt.Sprintf("yaml.Marshal(&supportPacket) Error: %s", err.Error()) + return nil, warning +} diff --git a/app/server_test.go b/app/server_test.go index f6b4f7e668..a334bf03cd 100644 --- a/app/server_test.go +++ b/app/server_test.go @@ -21,6 +21,7 @@ import ( "time" "github.com/getsentry/sentry-go" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/mattermost/mattermost-server/v5/config" @@ -161,6 +162,184 @@ func TestStartServerTLSSuccess(t *testing.T) { require.NoError(t, serverErr) } +func TestDatabaseTypeAndMattermostVersion(t *testing.T) { + sqlDrivernameEnvironment := os.Getenv("MM_SQLSETTINGS_DRIVERNAME") + defer os.Setenv("MM_SQLSETTINGS_DRIVERNAME", sqlDrivernameEnvironment) + + os.Setenv("MM_SQLSETTINGS_DRIVERNAME", "postgres") + + th := Setup(t) + defer th.TearDown() + + databaseType, mattermostVersion := th.Server.DatabaseTypeAndMattermostVersion() + assert.Equal(t, "postgres", databaseType) + assert.Equal(t, "5.31.0", mattermostVersion) + + os.Setenv("MM_SQLSETTINGS_DRIVERNAME", "mysql") + + th2 := Setup(t) + defer th2.TearDown() + + databaseType, mattermostVersion = th2.Server.DatabaseTypeAndMattermostVersion() + assert.Equal(t, "mysql", databaseType) + assert.Equal(t, "5.31.0", mattermostVersion) +} + +func TestGenerateSupportPacket(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + d1 := []byte("hello\ngo\n") + err := ioutil.WriteFile("mattermost.log", d1, 0777) + require.Nil(t, err) + err = ioutil.WriteFile("notifications.log", d1, 0777) + require.Nil(t, err) + + fileDatas := th.App.GenerateSupportPacket() + testFiles := []string{"support_packet.yaml", "plugins.json", "sanitized_config.json", "mattermost.log", "notifications.log"} + for i, fileData := range fileDatas { + require.NotNil(t, fileData) + assert.Equal(t, testFiles[i], fileData.Filename) + assert.Positive(t, len(fileData.Body)) + } + + // Remove these two files and ensure that warning.txt file is generated + err = os.Remove("notifications.log") + require.Nil(t, err) + err = os.Remove("mattermost.log") + require.Nil(t, err) + fileDatas = th.App.GenerateSupportPacket() + testFiles = []string{"support_packet.yaml", "plugins.json", "sanitized_config.json", "warning.txt"} + for i, fileData := range fileDatas { + require.NotNil(t, fileData) + assert.Equal(t, testFiles[i], fileData.Filename) + assert.Positive(t, len(fileData.Body)) + } +} + +func TestGetNotificationsLog(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + // Disable notifications file to get an error + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.NotificationLogSettings.EnableFile = false + }) + + fileData, warning := th.App.getNotificationsLog() + assert.Nil(t, fileData) + assert.Equal(t, warning, "Unable to retrieve notifications.log because LogSettings: EnableFile is false in config.json") + + // Enable notifications file but delete any notifications file to get an error trying to read the file + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.NotificationLogSettings.EnableFile = true + }) + + // If any previous notifications.log file, lets delete it + os.Remove("notifications.log") + + fileData, warning = th.App.getNotificationsLog() + assert.Nil(t, fileData) + assert.Contains(t, warning, "ioutil.ReadFile(notificationsLog) Error:") + + // Happy path where we have file and no warning + d1 := []byte("hello\ngo\n") + err := ioutil.WriteFile("notifications.log", d1, 0777) + defer os.Remove("notifications.log") + require.Nil(t, err) + + fileData, warning = th.App.getNotificationsLog() + require.NotNil(t, fileData) + assert.Equal(t, "notifications.log", fileData.Filename) + assert.Positive(t, len(fileData.Body)) + assert.Empty(t, warning) +} + +func TestGetMattermostLog(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + // disable mattermost log file setting in config so we should get an warning + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.LogSettings.EnableFile = false + }) + + fileData, warning := th.App.getMattermostLog() + assert.Nil(t, fileData) + assert.Equal(t, "Unable to retrieve mattermost.log because LogSettings: EnableFile is false in config.json", warning) + + // We enable the setting but delete any mattermost log file + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.LogSettings.EnableFile = true + }) + + // If any previous mattermost.log file, lets delete it + os.Remove("mattermost.log") + + fileData, warning = th.App.getMattermostLog() + assert.Nil(t, fileData) + assert.Contains(t, warning, "ioutil.ReadFile(mattermostLog) Error:") + + // Happy path where we get a log file and no warning + d1 := []byte("hello\ngo\n") + err := ioutil.WriteFile("mattermost.log", d1, 0777) + defer os.Remove("mattermost.log") + require.Nil(t, err) + + fileData, warning = th.App.getMattermostLog() + require.NotNil(t, fileData) + assert.Equal(t, "mattermost.log", fileData.Filename) + assert.Positive(t, len(fileData.Body)) + assert.Empty(t, warning) +} + +func TestCreateSanitizedConfigFile(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + // Happy path where we have a sanitized config file with no warning + fileData, warning := th.App.createSanitizedConfigFile() + require.NotNil(t, fileData) + assert.Equal(t, "sanitized_config.json", fileData.Filename) + assert.Positive(t, len(fileData.Body)) + assert.Empty(t, warning) +} + +func TestCreatePluginsFile(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + // Happy path where we have a plugins file with no warning + fileData, warning := th.App.createPluginsFile() + require.NotNil(t, fileData) + assert.Equal(t, "plugins.json", fileData.Filename) + assert.Positive(t, len(fileData.Body)) + assert.Empty(t, warning) + + // Turn off plugins so we can get an error + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = false + }) + + // Plugins off in settings so no fileData and we get a warning instead + fileData, warning = th.App.createPluginsFile() + assert.Nil(t, fileData) + assert.Contains(t, warning, "c.App.GetPlugins() Error:") +} + +func TestGenerateSupportPacketYaml(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + // Happy path where we have a support packet yaml file without any warnings + fileData, warning := th.App.generateSupportPacketYaml() + require.NotNil(t, fileData) + assert.Equal(t, "support_packet.yaml", fileData.Filename) + assert.Positive(t, len(fileData.Body)) + assert.Empty(t, warning) + +} + func TestStartServerTLSVersion(t *testing.T) { s, err := NewServer() require.NoError(t, err) diff --git a/config/client.go b/config/client.go index 02f967d630..7f2ffd87ac 100644 --- a/config/client.go +++ b/config/client.go @@ -222,6 +222,8 @@ func GenerateLimitedClientConfig(c *model.Config, telemetryID string, license *m props["BuildEnterpriseReady"] = model.BuildEnterpriseReady props["EnableBotAccountCreation"] = strconv.FormatBool(*c.ServiceSettings.EnableBotAccountCreation) + props["EnableFile"] = strconv.FormatBool(*c.LogSettings.EnableFile) + props["FileLevel"] = *c.LogSettings.FileLevel props["SiteName"] = *c.TeamSettings.SiteName props["WebsocketURL"] = strings.TrimRight(*c.ServiceSettings.WebsocketURL, "/") diff --git a/einterfaces/ldap.go b/einterfaces/ldap.go index fb91792eec..ef5fb56b39 100644 --- a/einterfaces/ldap.go +++ b/einterfaces/ldap.go @@ -23,4 +23,5 @@ type LdapInterface interface { FirstLoginSync(user *model.User, userAuthService, userAuthData, email string) *model.AppError UpdateProfilePictureIfNecessary(model.User, model.Session) GetADLdapIdFromSAMLId(authData string) string + GetVendorNameAndVendorVersion() (string, string) } diff --git a/einterfaces/mocks/LdapInterface.go b/einterfaces/mocks/LdapInterface.go index a2d2c3103c..b768b8a9de 100644 --- a/einterfaces/mocks/LdapInterface.go +++ b/einterfaces/mocks/LdapInterface.go @@ -233,6 +233,27 @@ func (_m *LdapInterface) GetUserAttributes(id string, attributes []string) (map[ return r0, r1 } +// GetVendorNameAndVendorVersion provides a mock function with given fields: +func (_m *LdapInterface) GetVendorNameAndVendorVersion() (string, string) { + ret := _m.Called() + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + var r1 string + if rf, ok := ret.Get(1).(func() string); ok { + r1 = rf() + } else { + r1 = ret.Get(1).(string) + } + + return r0, r1 +} + // MigrateIDAttribute provides a mock function with given fields: toAttribute func (_m *LdapInterface) MigrateIDAttribute(toAttribute string) error { ret := _m.Called(toAttribute) diff --git a/i18n/en.json b/i18n/en.json index 671ad26077..825d6eab38 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1580,6 +1580,10 @@ "id": "api.migrate_to_saml.error", "translation": "Unable to migrate SAML." }, + { + "id": "api.no_license", + "translation": "E10 or E20 license required to use this endpoint." + }, { "id": "api.oauth.allow_oauth.redirect_callback.app_error", "translation": "invalid_request: Supplied redirect_uri did not match registered callback_url." @@ -3050,6 +3054,18 @@ "id": "api.templates.welcome_subject", "translation": "[{{ .SiteName }}] You joined {{ .ServerURL }}" }, + { + "id": "api.unable_to_create_zip_file", + "translation": "Error creating zip file." + }, + { + "id": "api.unable_to_read_file_from_backend", + "translation": "Error reading file from backend" + }, + { + "id": "api.unable_write_file_response", + "translation": "Error downloading support packet file." + }, { "id": "api.upgrade_to_enterprise.already-done.app_error", "translation": "You have already upgraded to Mattermost Enterprise Edition. Please restart the server to finish the upgrade." diff --git a/model/client4.go b/model/client4.go index 58d23163c3..11b28c4b4c 100644 --- a/model/client4.go +++ b/model/client4.go @@ -3278,6 +3278,21 @@ func (c *Client4) GetFileInfosForPost(postId string, etag string) ([]*FileInfo, // General/System Section +// GenerateSupportPacket downloads the generated support packet +func (c *Client4) GenerateSupportPacket() ([]byte, *Response) { + r, appErr := c.DoApiGet(c.GetSystemRoute()+"/support_packet", "") + if appErr != nil { + return nil, BuildErrorResponse(r, appErr) + } + defer closeBody(r) + + data, err := ioutil.ReadAll(r.Body) + if err != nil { + return nil, BuildErrorResponse(r, NewAppError("GetFile", "model.client.read_job_result_file.app_error", nil, err.Error(), r.StatusCode)) + } + return data, BuildResponse(r) +} + // GetPing will return ok if the running goRoutines are below the threshold and unhealthy for above. func (c *Client4) GetPing() (string, *Response) { r, err := c.DoApiGet(c.GetSystemRoute()+"/ping", "") diff --git a/model/system.go b/model/system.go index 2b54dbc3c4..7f7742404c 100644 --- a/model/system.go +++ b/model/system.go @@ -87,6 +87,22 @@ type ServerBusyState struct { Expires_ts string `json:"expires_ts,omitempty"` } +type SupportPacket struct { + ServerOS string `yaml:"server_os"` + ServerArchitecture string `yaml:"server_architecture"` + DatabaseType string `yaml:"database_type"` + DatabaseVersion string `yaml:"database_version"` + LdapVendorName string `yaml:"ldap_vendor_name,omitempty"` + LdapVendorVersion string `yaml:"ldap_vendor_version,omitempty"` + ElasticServerVersion string `yaml:"elastic_server_version,omitempty"` + ElasticServerPlugins []string `yaml:"elastic_server_plugins,omitempty"` +} + +type FileData struct { + Filename string + Body []byte +} + func (sbs *ServerBusyState) ToJson() string { b, _ := json.Marshal(sbs) return string(b) diff --git a/services/searchengine/bleveengine/bleve.go b/services/searchengine/bleveengine/bleve.go index f5a3d2c080..04c6a70c8b 100644 --- a/services/searchengine/bleveengine/bleve.go +++ b/services/searchengine/bleveengine/bleve.go @@ -231,6 +231,14 @@ func (b *BleveEngine) GetVersion() int { return 0 } +func (b *BleveEngine) GetFullVersion() string { + return "0" +} + +func (b *BleveEngine) GetPlugins() []string { + return []string{} +} + func (b *BleveEngine) GetName() string { return EngineName } diff --git a/services/searchengine/interface.go b/services/searchengine/interface.go index da0f78154a..2fde8eec63 100644 --- a/services/searchengine/interface.go +++ b/services/searchengine/interface.go @@ -12,7 +12,9 @@ import ( type SearchEngineInterface interface { Start() *model.AppError Stop() *model.AppError + GetFullVersion() string GetVersion() int + GetPlugins() []string UpdateConfig(cfg *model.Config) GetName() string IsActive() bool diff --git a/services/searchengine/mocks/SearchEngineInterface.go b/services/searchengine/mocks/SearchEngineInterface.go index 19918f4d6c..40c95d96bb 100644 --- a/services/searchengine/mocks/SearchEngineInterface.go +++ b/services/searchengine/mocks/SearchEngineInterface.go @@ -176,6 +176,20 @@ func (_m *SearchEngineInterface) DeleteUserPosts(userID string) *model.AppError return r0 } +// GetFullVersion provides a mock function with given fields: +func (_m *SearchEngineInterface) GetFullVersion() string { + ret := _m.Called() + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + // GetName provides a mock function with given fields: func (_m *SearchEngineInterface) GetName() string { ret := _m.Called() @@ -190,6 +204,22 @@ func (_m *SearchEngineInterface) GetName() string { return r0 } +// GetPlugins provides a mock function with given fields: +func (_m *SearchEngineInterface) GetPlugins() []string { + ret := _m.Called() + + var r0 []string + if rf, ok := ret.Get(0).(func() []string); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + return r0 +} + // GetVersion provides a mock function with given fields: func (_m *SearchEngineInterface) GetVersion() int { ret := _m.Called() diff --git a/testlib/testdata/mysql_migration_warmup.sql b/testlib/testdata/mysql_migration_warmup.sql index 9b7ff47e7e..113ee67129 100644 --- a/testlib/testdata/mysql_migration_warmup.sql +++ b/testlib/testdata/mysql_migration_warmup.sql @@ -35,6 +35,7 @@ INSERT INTO `Systems` VALUES ('add_use_group_mentions_permission', 'true'); INSERT INTO `Systems` VALUES ('add_system_console_permissions', 'true'); INSERT INTO `Systems` VALUES ('add_convert_channel_permissions', 'true'); INSERT INTO `Systems` VALUES ('manage_shared_channel_permissions', 'true'); +INSERT INTO `Systems` VALUES ('Version', '5.31.0'); /*!40000 ALTER TABLE `Systems` ENABLE KEYS */; diff --git a/testlib/testdata/postgres_migration_warmup.sql b/testlib/testdata/postgres_migration_warmup.sql index 08b0ab693f..3f12e1df96 100644 --- a/testlib/testdata/postgres_migration_warmup.sql +++ b/testlib/testdata/postgres_migration_warmup.sql @@ -43,6 +43,7 @@ INSERT INTO public.systems VALUES ('add_use_group_mentions_permission', 'true'); INSERT INTO public.systems VALUES ('add_system_console_permissions', 'true'); INSERT INTO public.systems VALUES ('add_convert_channel_permissions', 'true'); INSERT INTO public.systems VALUES ('manage_shared_channel_permissions', 'true'); +INSERT INTO public.systems VALUES ('Version', '5.31.0'); -- -- PostgreSQL database dump complete