diff options
40 files changed, 938 insertions, 130 deletions
diff --git a/api4/params.go b/api4/params.go index 1f0fe8e63..64ee43771 100644 --- a/api4/params.go +++ b/api4/params.go @@ -11,9 +11,11 @@ import ( ) const ( - PAGE_DEFAULT = 0 - PER_PAGE_DEFAULT = 60 - PER_PAGE_MAXIMUM = 200 + PAGE_DEFAULT = 0 + PER_PAGE_DEFAULT = 60 + PER_PAGE_MAXIMUM = 200 + LOGS_PER_PAGE_DEFAULT = 10000 + LOGS_PER_PAGE_MAXIMUM = 10000 ) type ApiParams struct { @@ -43,6 +45,7 @@ type ApiParams struct { ActionId string Page int PerPage int + LogsPerPage int Permanent bool } @@ -165,5 +168,13 @@ func ApiParamsFromRequest(r *http.Request) *ApiParams { params.PerPage = val } + if val, err := strconv.Atoi(r.URL.Query().Get("logs_per_page")); err != nil || val < 0 { + params.LogsPerPage = LOGS_PER_PAGE_DEFAULT + } else if val > LOGS_PER_PAGE_MAXIMUM { + params.LogsPerPage = LOGS_PER_PAGE_MAXIMUM + } else { + params.LogsPerPage = val + } + return params } diff --git a/api4/plugin_test.go b/api4/plugin_test.go index 82e11f775..e385b5c8c 100644 --- a/api4/plugin_test.go +++ b/api4/plugin_test.go @@ -46,7 +46,7 @@ func TestPlugin(t *testing.T) { *cfg.PluginSettings.EnableUploads = true }) - th.App.InitPlugins(pluginDir, webappDir) + th.App.InitPlugins(pluginDir, webappDir, nil) defer func() { th.App.ShutDownPlugins() th.App.PluginEnv = nil diff --git a/api4/reaction_test.go b/api4/reaction_test.go index b64d42ce3..93cd754c9 100644 --- a/api4/reaction_test.go +++ b/api4/reaction_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "reflect" + "github.com/stretchr/testify/assert" "github.com/mattermost/mattermost-server/model" ) @@ -180,20 +180,15 @@ func TestGetReactions(t *testing.T) { rr, resp := Client.GetReactions(postId) CheckNoError(t, resp) - if len(rr) != 5 { - t.Fatal("reactions should returned correct length") - } - - if !reflect.DeepEqual(rr, reactions) { - t.Fatal("reactions should have matched") + assert.Len(t, rr, 5) + for _, r := range reactions { + assert.Contains(t, reactions, r) } rr, resp = Client.GetReactions("junk") CheckBadRequestStatus(t, resp) - if len(rr) != 0 { - t.Fatal("reactions should return empty") - } + assert.Empty(t, rr) _, resp = Client.GetReactions(GenerateTestId()) CheckForbiddenStatus(t, resp) diff --git a/api4/system.go b/api4/system.go index 7bc846766..93e9ddcd2 100644 --- a/api4/system.go +++ b/api4/system.go @@ -187,7 +187,7 @@ func getLogs(c *Context, w http.ResponseWriter, r *http.Request) { return } - lines, err := c.App.GetLogs(c.Params.Page, c.Params.PerPage) + lines, err := c.App.GetLogs(c.Params.Page, c.Params.LogsPerPage) if err != nil { c.Err = err return diff --git a/api4/system_test.go b/api4/system_test.go index 3afcf633c..1b2bb5d99 100644 --- a/api4/system_test.go +++ b/api4/system_test.go @@ -308,18 +308,18 @@ func TestGetLogs(t *testing.T) { logs, resp := th.SystemAdminClient.GetLogs(0, 10) CheckNoError(t, resp) - // if len(logs) != 10 { - // t.Log(len(logs)) - // t.Fatal("wrong length") - // } + if len(logs) != 10 { + t.Log(len(logs)) + t.Fatal("wrong length") + } logs, resp = th.SystemAdminClient.GetLogs(1, 10) CheckNoError(t, resp) - // if len(logs) != 10 { - // t.Log(len(logs)) - // t.Fatal("wrong length") - // } + if len(logs) != 10 { + t.Log(len(logs)) + t.Fatal("wrong length") + } logs, resp = th.SystemAdminClient.GetLogs(-1, -1) CheckNoError(t, resp) diff --git a/api4/user.go b/api4/user.go index 16b7f79a9..4f4185958 100644 --- a/api4/user.go +++ b/api4/user.go @@ -683,10 +683,18 @@ func updateUserActive(c *Context, w http.ResponseWriter, r *http.Request) { return } - if ruser, err := c.App.UpdateNonSSOUserActive(c.Params.UserId, active); err != nil { + var user *model.User + var err *model.AppError + + if user, err = c.App.GetUser(c.Params.UserId); err != nil { + c.Err = err + return + } + + if _, err := c.App.UpdateActive(user, active); err != nil { c.Err = err } else { - c.LogAuditWithUserId(ruser.Id, fmt.Sprintf("active=%v", active)) + c.LogAuditWithUserId(user.Id, fmt.Sprintf("active=%v", active)) ReturnStatusOK(w) } } diff --git a/api4/user_test.go b/api4/user_test.go index 9c554da54..e3f1935b4 100644 --- a/api4/user_test.go +++ b/api4/user_test.go @@ -1163,6 +1163,13 @@ func TestUpdateUserActive(t *testing.T) { _, resp = SystemAdminClient.UpdateUserActive(user.Id, false) CheckNoError(t, resp) + + authData := model.NewId() + result := <-th.App.Srv.Store.User().UpdateAuthData(user.Id, "random", &authData, "", true) + require.Nil(t, result.Err) + + _, resp = SystemAdminClient.UpdateUserActive(user.Id, false) + CheckNoError(t, resp) } func TestGetUsers(t *testing.T) { @@ -2123,7 +2130,9 @@ func TestSwitchAccount(t *testing.T) { defer func() { utils.SetIsLicensed(isLicensed) utils.SetLicense(license) - th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.ExperimentalEnableAuthenticationTransfer = enableAuthenticationTransfer }) + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.ExperimentalEnableAuthenticationTransfer = enableAuthenticationTransfer + }) }() utils.SetIsLicensed(true) utils.SetLicense(&model.License{Features: &model.Features{}}) diff --git a/app/admin.go b/app/admin.go index ef5a1c5d5..e46d9073c 100644 --- a/app/admin.go +++ b/app/admin.go @@ -4,7 +4,7 @@ package app import ( - "bufio" + "io" "os" "strings" "time" @@ -20,9 +20,6 @@ import ( ) func (a *App) GetLogs(page, perPage int) ([]string, *model.AppError) { - - perPage = 10000 - var lines []string if a.Cluster != nil && *a.Config().ClusterSettings.Enable { lines = append(lines, "-----------------------------------------------------------------------------------------------------------") @@ -62,20 +59,48 @@ func (a *App) GetLogsSkipSend(page, perPage int) ([]string, *model.AppError) { defer file.Close() - offsetCount := 0 - limitCount := 0 - scanner := bufio.NewScanner(file) - for scanner.Scan() { - if limitCount >= perPage { - break + var newLine = []byte{'\n'} + var lineCount int + const searchPos = -1 + lineEndPos, err := file.Seek(0, io.SeekEnd) + if err != nil { + return nil, model.NewAppError("getLogs", "api.admin.file_read_error", nil, err.Error(), http.StatusInternalServerError) + } + for { + pos, err := file.Seek(searchPos, io.SeekCurrent) + if err != nil { + return nil, model.NewAppError("getLogs", "api.admin.file_read_error", nil, err.Error(), http.StatusInternalServerError) } - if offsetCount >= page*perPage { - lines = append(lines, scanner.Text()) - limitCount++ - } else { - offsetCount++ + b := make([]byte, 1) + _, err = file.ReadAt(b, pos) + if err != nil { + return nil, model.NewAppError("getLogs", "api.admin.file_read_error", nil, err.Error(), http.StatusInternalServerError) } + + if b[0] == newLine[0] || pos == 0 { + lineCount++ + if lineCount > page*perPage { + line := make([]byte, lineEndPos-pos) + _, err := file.ReadAt(line, pos) + if err != nil { + return nil, model.NewAppError("getLogs", "api.admin.file_read_error", nil, err.Error(), http.StatusInternalServerError) + } + lines = append(lines, string(line)) + } + if pos == 0 { + break + } + lineEndPos = pos + } + + if len(lines) == perPage { + break + } + } + + for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 { + lines[i], lines[j] = lines[j], lines[i] } } else { lines = append(lines, "") diff --git a/app/app.go b/app/app.go index fd313c9c9..959c99306 100644 --- a/app/app.go +++ b/app/app.go @@ -9,6 +9,7 @@ import ( "net/http" "runtime/debug" "strings" + "sync" "sync/atomic" l4g "github.com/alecthomas/log4go" @@ -60,6 +61,9 @@ type App struct { sessionCache *utils.Cache roles map[string]*model.Role configListenerId string + + pluginCommands []*PluginCommand + pluginCommandsLock sync.RWMutex } var appCount = 0 diff --git a/app/apptestlib.go b/app/apptestlib.go index 63a064d7f..618ad809a 100644 --- a/app/apptestlib.go +++ b/app/apptestlib.go @@ -4,15 +4,21 @@ package app import ( + "encoding/json" + "io/ioutil" + "os" + "path/filepath" "time" + l4g "github.com/alecthomas/log4go" + "github.com/mattermost/mattermost-server/model" + "github.com/mattermost/mattermost-server/plugin" + "github.com/mattermost/mattermost-server/plugin/pluginenv" "github.com/mattermost/mattermost-server/store" "github.com/mattermost/mattermost-server/store/sqlstore" "github.com/mattermost/mattermost-server/store/storetest" "github.com/mattermost/mattermost-server/utils" - - l4g "github.com/alecthomas/log4go" ) type TestHelper struct { @@ -22,6 +28,9 @@ type TestHelper struct { BasicUser2 *model.User BasicChannel *model.Channel BasicPost *model.Post + + tempWorkspace string + pluginHooks map[string]plugin.Hooks } type persistentTestStore struct { @@ -54,7 +63,8 @@ func setupTestHelper(enterprise bool) *TestHelper { } th := &TestHelper{ - App: New(options...), + App: New(options...), + pluginHooks: make(map[string]plugin.Hooks), } th.App.UpdateConfig(func(cfg *model.Config) { *cfg.TeamSettings.MaxUsersPerTeam = 50 }) @@ -223,4 +233,61 @@ func (me *TestHelper) TearDown() { StopTestStore() panic(err) } + if me.tempWorkspace != "" { + os.RemoveAll(me.tempWorkspace) + } +} + +type mockPluginSupervisor struct { + hooks plugin.Hooks +} + +func (s *mockPluginSupervisor) Start(api plugin.API) error { + return s.hooks.OnActivate(api) +} + +func (s *mockPluginSupervisor) Stop() error { + return nil +} + +func (s *mockPluginSupervisor) Hooks() plugin.Hooks { + return s.hooks +} + +func (me *TestHelper) InstallPlugin(manifest *model.Manifest, hooks plugin.Hooks) { + if me.tempWorkspace == "" { + dir, err := ioutil.TempDir("", "apptest") + if err != nil { + panic(err) + } + me.tempWorkspace = dir + } + + pluginDir := filepath.Join(me.tempWorkspace, "plugins") + webappDir := filepath.Join(me.tempWorkspace, "webapp") + me.App.InitPlugins(pluginDir, webappDir, func(bundle *model.BundleInfo) (plugin.Supervisor, error) { + if hooks, ok := me.pluginHooks[bundle.Manifest.Id]; ok { + return &mockPluginSupervisor{hooks}, nil + } + return pluginenv.DefaultSupervisorProvider(bundle) + }) + + me.pluginHooks[manifest.Id] = hooks + + manifestCopy := *manifest + if manifestCopy.Backend == nil { + manifestCopy.Backend = &model.ManifestBackend{} + } + manifestBytes, err := json.Marshal(&manifestCopy) + if err != nil { + panic(err) + } + + if err := os.MkdirAll(filepath.Join(pluginDir, manifest.Id), 0700); err != nil { + panic(err) + } + + if err := ioutil.WriteFile(filepath.Join(pluginDir, manifest.Id, "plugin.json"), manifestBytes, 0600); err != nil { + panic(err) + } } diff --git a/app/channel.go b/app/channel.go index caaacea06..d37f681bb 100644 --- a/app/channel.go +++ b/app/channel.go @@ -64,7 +64,7 @@ func (a *App) JoinDefaultChannels(teamId string, user *model.User, channelRole s } if requestor == nil { - if err := a.postJoinChannelMessage(user, townSquare); err != nil { + if err := a.postJoinTeamMessage(user, townSquare); err != nil { l4g.Error(utils.T("api.channel.post_user_add_remove_message_and_forget.error"), err) } } else { @@ -78,8 +78,7 @@ func (a *App) JoinDefaultChannels(teamId string, user *model.User, channelRole s if result := <-a.Srv.Store.Channel().GetByName(teamId, "off-topic", true); result.Err != nil { err = result.Err - } else { - offTopic := result.Data.(*model.Channel) + } else if offTopic := result.Data.(*model.Channel); offTopic.Type == model.CHANNEL_OPEN { cm := &model.ChannelMember{ ChannelId: offTopic.Id, @@ -965,6 +964,24 @@ func (a *App) postJoinChannelMessage(user *model.User, channel *model.Channel) * return nil } +func (a *App) postJoinTeamMessage(user *model.User, channel *model.Channel) *model.AppError { + post := &model.Post{ + ChannelId: channel.Id, + Message: fmt.Sprintf(utils.T("api.team.join_team.post_and_forget"), user.Username), + Type: model.POST_JOIN_TEAM, + UserId: user.Id, + Props: model.StringInterface{ + "username": user.Username, + }, + } + + if _, err := a.CreatePost(post, channel, false); err != nil { + return model.NewAppError("postJoinTeamMessage", "api.channel.post_user_add_remove_message_and_forget.error", nil, err.Error(), http.StatusInternalServerError) + } + + return nil +} + func (a *App) LeaveChannel(channelId string, userId string) *model.AppError { sc := a.Srv.Store.Channel().Get(channelId, true) uc := a.Srv.Store.User().Get(userId) diff --git a/app/command.go b/app/command.go index dc65de6e2..4c26eae71 100644 --- a/app/command.go +++ b/app/command.go @@ -75,6 +75,13 @@ func (a *App) ListAutocompleteCommands(teamId string, T goi18n.TranslateFunc) ([ } } + for _, cmd := range a.PluginCommandsForTeam(teamId) { + if cmd.AutoComplete && !seen[cmd.Trigger] { + seen[cmd.Trigger] = true + commands = append(commands, cmd) + } + } + if *a.Config().ServiceSettings.EnableCommands { if result := <-a.Srv.Store.Command().GetByTeam(teamId); result.Err != nil { return nil, result.Err @@ -111,7 +118,7 @@ func (a *App) ListAllCommands(teamId string, T goi18n.TranslateFunc) ([]*model.C for _, value := range commandProviders { if cmd := value.GetCommand(a, T); cmd != nil { cpy := *cmd - if cpy.AutoComplete && !seen[cpy.Id] { + if cpy.AutoComplete && !seen[cpy.Trigger] { cpy.Sanitize() seen[cpy.Trigger] = true commands = append(commands, &cpy) @@ -119,13 +126,20 @@ func (a *App) ListAllCommands(teamId string, T goi18n.TranslateFunc) ([]*model.C } } + for _, cmd := range a.PluginCommandsForTeam(teamId) { + if !seen[cmd.Trigger] { + seen[cmd.Trigger] = true + commands = append(commands, cmd) + } + } + if *a.Config().ServiceSettings.EnableCommands { if result := <-a.Srv.Store.Command().GetByTeam(teamId); result.Err != nil { return nil, result.Err } else { teamCmds := result.Data.([]*model.Command) for _, cmd := range teamCmds { - if !seen[cmd.Id] { + if !seen[cmd.Trigger] { cmd.Sanitize() seen[cmd.Trigger] = true commands = append(commands, cmd) @@ -151,6 +165,12 @@ func (a *App) ExecuteCommand(args *model.CommandArgs) (*model.CommandResponse, * } } + if cmd, response, err := a.ExecutePluginCommand(args); err != nil { + return nil, err + } else if cmd != nil { + return a.HandleCommandResponse(cmd, args, response, true) + } + if !*a.Config().ServiceSettings.EnableCommands { return nil, model.NewAppError("ExecuteCommand", "api.command.disabled.app_error", nil, "", http.StatusNotImplemented) } diff --git a/app/notification.go b/app/notification.go index 3b21e86c5..a7093e17f 100644 --- a/app/notification.go +++ b/app/notification.go @@ -935,7 +935,7 @@ func (a *App) GetMentionKeywordsInChannel(profiles map[string]*model.User, lookF // Add @channel and @all to keywords if user has them turned on if lookForSpecialMentions { - if int64(len(profiles)) < *a.Config().TeamSettings.MaxNotificationsPerChannel && profile.NotifyProps["channel"] == "true" { + if int64(len(profiles)) <= *a.Config().TeamSettings.MaxNotificationsPerChannel && profile.NotifyProps["channel"] == "true" { keywords["@channel"] = append(keywords["@channel"], profile.Id) keywords["@all"] = append(keywords["@all"], profile.Id) diff --git a/app/notification_test.go b/app/notification_test.go index 5ae765649..c5d0f8478 100644 --- a/app/notification_test.go +++ b/app/notification_test.go @@ -544,7 +544,8 @@ func TestGetMentionKeywords(t *testing.T) { return duplicate_frequency } - // multiple users + // multiple users but no more than MaxNotificationsPerChannel + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.TeamSettings.MaxNotificationsPerChannel = 4 }) profiles = map[string]*model.User{ user1.Id: user1, user2.Id: user2, @@ -568,6 +569,19 @@ func TestGetMentionKeywords(t *testing.T) { t.Fatal("should've mentioned user3 and user4 with @all") } + // multiple users and more than MaxNotificationsPerChannel + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.TeamSettings.MaxNotificationsPerChannel = 3 }) + mentions = th.App.GetMentionKeywordsInChannel(profiles, true) + if len(mentions) != 4 { + t.Fatal("should've returned four mention keywords") + } else if _, ok := mentions["@channel"]; ok { + t.Fatal("should not have mentioned any user with @channel") + } else if _, ok := mentions["@all"]; ok { + t.Fatal("should not have mentioned any user with @all") + } else if _, ok := mentions["@here"]; ok { + t.Fatal("should not have mentioned any user with @here") + } + // no special mentions profiles = map[string]*model.User{ user1.Id: user1, diff --git a/app/plugin.go b/app/plugin.go index f91a2e414..661f6ed5d 100644 --- a/app/plugin.go +++ b/app/plugin.go @@ -8,6 +8,7 @@ import ( "context" "crypto/sha256" "encoding/base64" + "fmt" "io" "io/ioutil" "net/http" @@ -101,20 +102,28 @@ func (a *App) ActivatePlugins() { l4g.Info("Activated %v plugin", id) } else if !pluginState.Enable && active { - if err := a.PluginEnv.DeactivatePlugin(id); err != nil { + if err := a.deactivatePlugin(plugin.Manifest); err != nil { l4g.Error(err.Error()) - continue } + } + } +} - if plugin.Manifest.HasClient() { - message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_PLUGIN_DEACTIVATED, "", "", "", nil) - message.Add("manifest", plugin.Manifest.ClientManifest()) - a.Publish(message) - } +func (a *App) deactivatePlugin(manifest *model.Manifest) *model.AppError { + if err := a.PluginEnv.DeactivatePlugin(manifest.Id); err != nil { + return model.NewAppError("removePlugin", "app.plugin.deactivate.app_error", nil, err.Error(), http.StatusBadRequest) + } - l4g.Info("Deactivated %v plugin", id) - } + a.UnregisterPluginCommands(manifest.Id) + + if manifest.HasClient() { + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_PLUGIN_DEACTIVATED, "", "", "", nil) + message.Add("manifest", manifest.ClientManifest()) + a.Publish(message) } + + l4g.Info("Deactivated %v plugin", manifest.Id) + return nil } // InstallPlugin unpacks and installs a plugin but does not activate it. @@ -253,15 +262,9 @@ func (a *App) removePlugin(id string, allowPrepackaged bool) *model.AppError { } if a.PluginEnv.IsPluginActive(id) { - err := a.PluginEnv.DeactivatePlugin(id) + err := a.deactivatePlugin(manifest) if err != nil { - return model.NewAppError("removePlugin", "app.plugin.deactivate.app_error", nil, err.Error(), http.StatusBadRequest) - } - - if manifest.HasClient() { - message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_PLUGIN_DEACTIVATED, "", "", "", nil) - message.Add("manifest", manifest.ClientManifest()) - a.Publish(message) + return err } } @@ -341,7 +344,7 @@ func (a *App) DisablePlugin(id string) *model.AppError { return nil } -func (a *App) InitPlugins(pluginPath, webappPath string) { +func (a *App) InitPlugins(pluginPath, webappPath string, supervisorOverride pluginenv.SupervisorProviderFunc) { if !*a.Config().PluginSettings.Enable { return } @@ -362,7 +365,7 @@ func (a *App) InitPlugins(pluginPath, webappPath string) { return } - if env, err := pluginenv.New( + options := []pluginenv.Option{ pluginenv.SearchPath(pluginPath), pluginenv.WebappPath(webappPath), pluginenv.APIProvider(func(m *model.Manifest) (plugin.API, error) { @@ -375,7 +378,13 @@ func (a *App) InitPlugins(pluginPath, webappPath string) { }, }, nil }), - ); err != nil { + } + + if supervisorOverride != nil { + options = append(options, pluginenv.SupervisorProvider(supervisorOverride)) + } + + if env, err := pluginenv.New(options...); err != nil { l4g.Error("failed to start up plugins: " + err.Error()) return } else { @@ -533,3 +542,101 @@ func (a *App) DeletePluginKey(pluginId string, key string) *model.AppError { return result.Err } + +type PluginCommand struct { + Command *model.Command + PluginId string +} + +func (a *App) RegisterPluginCommand(pluginId string, command *model.Command) error { + if command.Trigger == "" { + return fmt.Errorf("invalid command") + } + + command = &model.Command{ + Trigger: strings.ToLower(command.Trigger), + TeamId: command.TeamId, + AutoComplete: command.AutoComplete, + AutoCompleteDesc: command.AutoCompleteDesc, + DisplayName: command.DisplayName, + } + + a.pluginCommandsLock.Lock() + defer a.pluginCommandsLock.Unlock() + + for _, pc := range a.pluginCommands { + if pc.Command.Trigger == command.Trigger && pc.Command.TeamId == command.TeamId { + if pc.PluginId == pluginId { + pc.Command = command + return nil + } + } + } + + a.pluginCommands = append(a.pluginCommands, &PluginCommand{ + Command: command, + PluginId: pluginId, + }) + return nil +} + +func (a *App) UnregisterPluginCommand(pluginId, teamId, trigger string) { + trigger = strings.ToLower(trigger) + + a.pluginCommandsLock.Lock() + defer a.pluginCommandsLock.Unlock() + + var remaining []*PluginCommand + for _, pc := range a.pluginCommands { + if pc.Command.TeamId != teamId || pc.Command.Trigger != trigger { + remaining = append(remaining, pc) + } + } + a.pluginCommands = remaining +} + +func (a *App) UnregisterPluginCommands(pluginId string) { + a.pluginCommandsLock.Lock() + defer a.pluginCommandsLock.Unlock() + + var remaining []*PluginCommand + for _, pc := range a.pluginCommands { + if pc.PluginId != pluginId { + remaining = append(remaining, pc) + } + } + a.pluginCommands = remaining +} + +func (a *App) PluginCommandsForTeam(teamId string) []*model.Command { + a.pluginCommandsLock.RLock() + defer a.pluginCommandsLock.RUnlock() + + var commands []*model.Command + for _, pc := range a.pluginCommands { + if pc.Command.TeamId == "" || pc.Command.TeamId == teamId { + commands = append(commands, pc.Command) + } + } + return commands +} + +func (a *App) ExecutePluginCommand(args *model.CommandArgs) (*model.Command, *model.CommandResponse, *model.AppError) { + parts := strings.Split(args.Command, " ") + trigger := parts[0][1:] + trigger = strings.ToLower(trigger) + + a.pluginCommandsLock.RLock() + defer a.pluginCommandsLock.RUnlock() + + for _, pc := range a.pluginCommands { + if (pc.Command.TeamId == "" || pc.Command.TeamId == args.TeamId) && pc.Command.Trigger == trigger { + response, appErr, err := a.PluginEnv.HooksForPlugin(pc.PluginId).ExecuteCommand(args) + if err != nil { + return pc.Command, nil, model.NewAppError("ExecutePluginCommand", "model.plugin_command.error.app_error", nil, "err="+err.Error(), http.StatusInternalServerError) + } + return pc.Command, response, appErr + } + } + return nil, nil, nil +} diff --git a/app/plugin_api.go b/app/plugin_api.go index 9965f770a..21b828368 100644 --- a/app/plugin_api.go +++ b/app/plugin_api.go @@ -34,6 +34,15 @@ func (api *PluginAPI) LoadPluginConfiguration(dest interface{}) error { } } +func (api *PluginAPI) RegisterCommand(command *model.Command) error { + return api.app.RegisterPluginCommand(api.id, command) +} + +func (api *PluginAPI) UnregisterCommand(teamId, trigger string) error { + api.app.UnregisterPluginCommand(api.id, teamId, trigger) + return nil +} + func (api *PluginAPI) CreateTeam(team *model.Team) (*model.Team, *model.AppError) { return api.app.CreateTeam(team) } diff --git a/app/plugin_test.go b/app/plugin_test.go index 5c70cbc4f..4794d2704 100644 --- a/app/plugin_test.go +++ b/app/plugin_test.go @@ -13,6 +13,8 @@ import ( "github.com/stretchr/testify/require" "github.com/mattermost/mattermost-server/model" + "github.com/mattermost/mattermost-server/plugin" + "github.com/mattermost/mattermost-server/plugin/plugintest" ) func TestPluginKeyValueStore(t *testing.T) { @@ -98,3 +100,98 @@ func TestHandlePluginRequest(t *testing.T) { } router.ServeHTTP(nil, r) } + +type testPlugin struct { + plugintest.Hooks +} + +func (p *testPlugin) OnConfigurationChange() error { + return nil +} + +func (p *testPlugin) OnDeactivate() error { + return nil +} + +type pluginCommandTestPlugin struct { + testPlugin + + TeamId string +} + +func (p *pluginCommandTestPlugin) OnActivate(api plugin.API) error { + if err := api.RegisterCommand(&model.Command{ + Trigger: "foo", + TeamId: p.TeamId, + }); err != nil { + return err + } + if err := api.RegisterCommand(&model.Command{ + Trigger: "foo2", + TeamId: p.TeamId, + }); err != nil { + return err + } + return api.UnregisterCommand(p.TeamId, "foo2") +} + +func (p *pluginCommandTestPlugin) ExecuteCommand(args *model.CommandArgs) (*model.CommandResponse, *model.AppError) { + if args.Command == "/foo" { + return &model.CommandResponse{ + Text: "bar", + }, nil + } + return nil, model.NewAppError("ExecuteCommand", "this is an error", nil, "", http.StatusBadRequest) +} + +func TestPluginCommands(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + th.InstallPlugin(&model.Manifest{ + Id: "foo", + }, &pluginCommandTestPlugin{ + TeamId: th.BasicTeam.Id, + }) + + require.Nil(t, th.App.EnablePlugin("foo")) + + resp, err := th.App.ExecuteCommand(&model.CommandArgs{ + Command: "/foo2", + TeamId: th.BasicTeam.Id, + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + }) + require.NotNil(t, err) + assert.Equal(t, http.StatusNotFound, err.StatusCode) + + resp, err = th.App.ExecuteCommand(&model.CommandArgs{ + Command: "/foo", + TeamId: th.BasicTeam.Id, + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + }) + require.Nil(t, err) + assert.Equal(t, "bar", resp.Text) + + resp, err = th.App.ExecuteCommand(&model.CommandArgs{ + Command: "/foo baz", + TeamId: th.BasicTeam.Id, + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + }) + require.NotNil(t, err) + require.Equal(t, "this is an error", err.Message) + assert.Nil(t, resp) + + require.Nil(t, th.App.RemovePlugin("foo")) + + resp, err = th.App.ExecuteCommand(&model.CommandArgs{ + Command: "/foo", + TeamId: th.BasicTeam.Id, + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + }) + require.NotNil(t, err) + assert.Equal(t, http.StatusNotFound, err.StatusCode) +} diff --git a/cmd/platform/server.go b/cmd/platform/server.go index 67e2dcc56..4c36f5a46 100644 --- a/cmd/platform/server.go +++ b/cmd/platform/server.go @@ -75,10 +75,10 @@ func runServer(configFileLocation string) { a.LoadLicense() } - a.InitPlugins(*a.Config().PluginSettings.Directory, *a.Config().PluginSettings.ClientDirectory) + a.InitPlugins(*a.Config().PluginSettings.Directory, *a.Config().PluginSettings.ClientDirectory, nil) utils.AddConfigListener(func(prevCfg, cfg *model.Config) { if *cfg.PluginSettings.Enable { - a.InitPlugins(*cfg.PluginSettings.Directory, *a.Config().PluginSettings.ClientDirectory) + a.InitPlugins(*cfg.PluginSettings.Directory, *a.Config().PluginSettings.ClientDirectory, nil) } else { a.ShutDownPlugins() } diff --git a/cmd/platform/user.go b/cmd/platform/user.go index e5e068023..0913609f1 100644 --- a/cmd/platform/user.go +++ b/cmd/platform/user.go @@ -184,8 +184,8 @@ func changeUserActiveStatus(a *app.App, user *model.User, userArg string, activa if user == nil { return fmt.Errorf("Can't find user '%v'", userArg) } - if user.IsLDAPUser() { - return errors.New("You can not modify the activation status of AD/LDAP accounts. Please modify through the AD/LDAP server.") + if user.IsSSOUser() { + fmt.Println("You must also deactivate this user in the SSO provider or they will be reactivated on next login or sync.") } if _, err := a.UpdateActive(user, activate); err != nil { return fmt.Errorf("Unable to change activation status of user: %v", userArg) diff --git a/config/default.json b/config/default.json index 14acb47d0..d772f2576 100644 --- a/config/default.json +++ b/config/default.json @@ -54,7 +54,8 @@ "EnableUserStatuses": true, "ClusterLogTimeoutMilliseconds": 2000, "EnablePreviewFeatures": true, - "CloseUnusedDirectMessages": false + "CloseUnusedDirectMessages": false, + "EnableTutorial": true }, "TeamSettings": { "SiteName": "Mattermost", diff --git a/i18n/en.json b/i18n/en.json index 0bcb4b711..b3cb2acb8 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2287,6 +2287,10 @@ "translation": "Email must be from a specific domain (e.g. @example.com). Please ask your systems administrator for details." }, { + "id": "api.team.join_team.post_and_forget", + "translation": "%v joined the team." + }, + { "id": "api.team.leave.left", "translation": "%v left the team." }, @@ -4263,6 +4267,18 @@ "translation": "Current working directory is %v" }, { + "id": "model.plugin_command.error.app_error", + "translation": "An error occurred while trying to execute this command." + }, + { + "id": "model.plugin_key_value.is_valid.plugin_id.app_error", + "translation": "Invalid plugin ID, must be more than {{.Min}} and a of maximum {{.Max}} characters long." + }, + { + "id": "model.plugin_key_value.is_valid.key.app_error", + "translation": "Invalid key, must be more than {{.Min}} and a of maximum {{.Max}} characters long." + }, + { "id": "model.access.is_valid.access_token.app_error", "translation": "Invalid access token" }, diff --git a/model/client4.go b/model/client4.go index 916e9d6de..d37fb3b0f 100644 --- a/model/client4.go +++ b/model/client4.go @@ -2649,7 +2649,7 @@ func (c *Client4) UploadBrandImage(data []byte) (bool, *Response) { // GetLogs page of logs as a string array. func (c *Client4) GetLogs(page, perPage int) ([]string, *Response) { - query := fmt.Sprintf("?page=%v&per_page=%v", page, perPage) + query := fmt.Sprintf("?page=%v&logs_per_page=%v", page, perPage) if r, err := c.DoApiGet("/logs"+query, ""); err != nil { return nil, BuildErrorResponse(r, err) } else { diff --git a/model/config.go b/model/config.go index 544f9b33f..918d15b9a 100644 --- a/model/config.go +++ b/model/config.go @@ -212,6 +212,7 @@ type ServiceSettings struct { ClusterLogTimeoutMilliseconds *int CloseUnusedDirectMessages *bool EnablePreviewFeatures *bool + EnableTutorial *bool } func (s *ServiceSettings) SetDefaults() { @@ -331,6 +332,10 @@ func (s *ServiceSettings) SetDefaults() { s.CloseUnusedDirectMessages = NewBool(false) } + if s.EnableTutorial == nil { + s.EnableTutorial = NewBool(true) + } + if s.SessionLengthWebInDays == nil { s.SessionLengthWebInDays = NewInt(30) } diff --git a/model/post.go b/model/post.go index b7b38e7ad..3873e6113 100644 --- a/model/post.go +++ b/model/post.go @@ -20,6 +20,7 @@ const ( POST_JOIN_LEAVE = "system_join_leave" // Deprecated, use POST_JOIN_CHANNEL or POST_LEAVE_CHANNEL instead POST_JOIN_CHANNEL = "system_join_channel" POST_LEAVE_CHANNEL = "system_leave_channel" + POST_JOIN_TEAM = "system_join_team" POST_LEAVE_TEAM = "system_leave_team" POST_ADD_REMOVE = "system_add_remove" // Deprecated, use POST_ADD_TO_CHANNEL or POST_REMOVE_FROM_CHANNEL instead POST_ADD_TO_CHANNEL = "system_add_to_channel" @@ -169,13 +170,27 @@ func (o *Post) IsValid() *AppError { return NewAppError("Post.IsValid", "model.post.is_valid.hashtags.app_error", nil, "id="+o.Id, http.StatusBadRequest) } - if !(o.Type == POST_DEFAULT || o.Type == POST_JOIN_LEAVE || o.Type == POST_ADD_REMOVE || - o.Type == POST_JOIN_CHANNEL || o.Type == POST_LEAVE_CHANNEL || o.Type == POST_LEAVE_TEAM || - o.Type == POST_REMOVE_FROM_CHANNEL || o.Type == POST_ADD_TO_CHANNEL || o.Type == POST_ADD_TO_TEAM || - o.Type == POST_SLACK_ATTACHMENT || o.Type == POST_HEADER_CHANGE || o.Type == POST_PURPOSE_CHANGE || - o.Type == POST_DISPLAYNAME_CHANGE || o.Type == POST_CHANNEL_DELETED || - strings.HasPrefix(o.Type, POST_CUSTOM_TYPE_PREFIX)) { - return NewAppError("Post.IsValid", "model.post.is_valid.type.app_error", nil, "id="+o.Type, http.StatusBadRequest) + switch o.Type { + case + POST_DEFAULT, + POST_JOIN_LEAVE, + POST_ADD_REMOVE, + POST_JOIN_CHANNEL, + POST_LEAVE_CHANNEL, + POST_LEAVE_TEAM, + POST_REMOVE_FROM_CHANNEL, + POST_ADD_TO_CHANNEL, + POST_ADD_TO_TEAM, + POST_JOIN_TEAM, + POST_SLACK_ATTACHMENT, + POST_HEADER_CHANGE, + POST_PURPOSE_CHANGE, + POST_DISPLAYNAME_CHANGE, + POST_CHANNEL_DELETED: + default: + if !strings.HasPrefix(o.Type, POST_CUSTOM_TYPE_PREFIX) { + return NewAppError("Post.IsValid", "model.post.is_valid.type.app_error", nil, "id="+o.Type, http.StatusBadRequest) + } } if utf8.RuneCountInString(ArrayToJson(o.Filenames)) > POST_FILENAMES_MAX_RUNES { diff --git a/plugin/api.go b/plugin/api.go index fee55eeff..437188f6e 100644 --- a/plugin/api.go +++ b/plugin/api.go @@ -16,6 +16,13 @@ type API interface { // struct that the configuration JSON can be unmarshalled to. LoadPluginConfiguration(dest interface{}) error + // RegisterCommand registers a custom slash command. When the command is triggered, your plugin + // can fulfill it via the ExecuteCommand hook. + RegisterCommand(command *model.Command) error + + // UnregisterCommand unregisters a command previously registered via RegisterCommand. + UnregisterCommand(teamId, trigger string) error + // CreateUser creates a user. CreateUser(user *model.User) (*model.User, *model.AppError) diff --git a/plugin/hooks.go b/plugin/hooks.go index 04d5c7c14..814609e8c 100644 --- a/plugin/hooks.go +++ b/plugin/hooks.go @@ -5,6 +5,8 @@ package plugin import ( "net/http" + + "github.com/mattermost/mattermost-server/model" ) // Methods from the Hooks interface can be used by a plugin to respond to events. Methods are likely @@ -30,4 +32,8 @@ type Hooks interface { // The Mattermost-User-Id header will be present if (and only if) the request is by an // authenticated user. ServeHTTP(http.ResponseWriter, *http.Request) + + // ExecuteCommand executes a command that has been previously registered via the RegisterCommand + // API. + ExecuteCommand(args *model.CommandArgs) (*model.CommandResponse, *model.AppError) } diff --git a/plugin/pluginenv/environment.go b/plugin/pluginenv/environment.go index 26511c651..f53021f74 100644 --- a/plugin/pluginenv/environment.go +++ b/plugin/pluginenv/environment.go @@ -223,35 +223,59 @@ func (env *Environment) Shutdown() (errs []error) { return } -type EnvironmentHooks struct { +type MultiPluginHooks struct { env *Environment } -func (env *Environment) Hooks() *EnvironmentHooks { - return &EnvironmentHooks{env} +type SinglePluginHooks struct { + env *Environment + pluginId string } -// OnConfigurationChange invokes the OnConfigurationChange hook for all plugins. Any errors -// encountered will be returned. -func (h *EnvironmentHooks) OnConfigurationChange() (errs []error) { +func (env *Environment) Hooks() *MultiPluginHooks { + return &MultiPluginHooks{ + env: env, + } +} + +func (env *Environment) HooksForPlugin(id string) *SinglePluginHooks { + return &SinglePluginHooks{ + env: env, + pluginId: id, + } +} + +func (h *MultiPluginHooks) invoke(f func(plugin.Hooks) error) (errs []error) { h.env.mutex.RLock() defer h.env.mutex.RUnlock() + for _, activePlugin := range h.env.activePlugins { if activePlugin.Supervisor == nil { continue } - if err := activePlugin.Supervisor.Hooks().OnConfigurationChange(); err != nil { - errs = append(errs, errors.Wrapf(err, "OnConfigurationChange error for %v", activePlugin.BundleInfo.Manifest.Id)) + if err := f(activePlugin.Supervisor.Hooks()); err != nil { + errs = append(errs, errors.Wrapf(err, "hook error for %v", activePlugin.BundleInfo.Manifest.Id)) } } return } +// OnConfigurationChange invokes the OnConfigurationChange hook for all plugins. Any errors +// encountered will be returned. +func (h *MultiPluginHooks) OnConfigurationChange() []error { + return h.invoke(func(hooks plugin.Hooks) error { + if err := hooks.OnConfigurationChange(); err != nil { + return errors.Wrapf(err, "error calling OnConfigurationChange hook") + } + return nil + }) +} + // ServeHTTP invokes the ServeHTTP hook for the plugin identified by the request or responds with a // 404 not found. // // It expects the request's context to have a plugin_id set. -func (h *EnvironmentHooks) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func (h *MultiPluginHooks) ServeHTTP(w http.ResponseWriter, r *http.Request) { if id := r.Context().Value("plugin_id"); id != nil { if idstr, ok := id.(string); ok { h.env.mutex.RLock() @@ -264,3 +288,25 @@ func (h *EnvironmentHooks) ServeHTTP(w http.ResponseWriter, r *http.Request) { } http.NotFound(w, r) } + +func (h *SinglePluginHooks) invoke(f func(plugin.Hooks) error) error { + h.env.mutex.RLock() + defer h.env.mutex.RUnlock() + + if activePlugin, ok := h.env.activePlugins[h.pluginId]; ok && activePlugin.Supervisor != nil { + if err := f(activePlugin.Supervisor.Hooks()); err != nil { + return errors.Wrapf(err, "hook error for plugin: %v", activePlugin.BundleInfo.Manifest.Id) + } + return nil + } + return fmt.Errorf("unable to invoke hook for plugin: %v", h.pluginId) +} + +// ExecuteCommand invokes the ExecuteCommand hook for the plugin. +func (h *SinglePluginHooks) ExecuteCommand(args *model.CommandArgs) (resp *model.CommandResponse, appErr *model.AppError, err error) { + err = h.invoke(func(hooks plugin.Hooks) error { + resp, appErr = hooks.ExecuteCommand(args) + return nil + }) + return +} diff --git a/plugin/pluginenv/environment_test.go b/plugin/pluginenv/environment_test.go index 988e5b08f..2a52b3830 100644 --- a/plugin/pluginenv/environment_test.go +++ b/plugin/pluginenv/environment_test.go @@ -355,3 +355,51 @@ func TestEnvironment_ConcurrentHookInvocations(t *testing.T) { wg.Wait() } + +func TestEnvironment_HooksForPlugins(t *testing.T) { + dir := initTmpDir(t, map[string]string{ + "foo/plugin.json": `{"id": "foo", "backend": {}}`, + }) + defer os.RemoveAll(dir) + + var provider MockProvider + defer provider.AssertExpectations(t) + + env, err := New( + SearchPath(dir), + APIProvider(provider.API), + SupervisorProvider(provider.Supervisor), + ) + require.NoError(t, err) + defer env.Shutdown() + + var api struct{ plugin.API } + var supervisor MockSupervisor + defer supervisor.AssertExpectations(t) + var hooks plugintest.Hooks + defer hooks.AssertExpectations(t) + + provider.On("API").Return(&api, nil) + provider.On("Supervisor").Return(&supervisor, nil) + + supervisor.On("Start", &api).Return(nil) + supervisor.On("Stop").Return(nil) + supervisor.On("Hooks").Return(&hooks) + + hooks.On("OnDeactivate").Return(nil) + hooks.On("ExecuteCommand", mock.AnythingOfType("*model.CommandArgs")).Return(&model.CommandResponse{ + Text: "bar", + }, nil) + + assert.NoError(t, env.ActivatePlugin("foo")) + assert.Equal(t, env.ActivePluginIds(), []string{"foo"}) + + resp, appErr, err := env.HooksForPlugin("foo").ExecuteCommand(&model.CommandArgs{ + Command: "/foo", + }) + assert.Equal(t, "bar", resp.Text) + assert.Nil(t, appErr) + assert.NoError(t, err) + + assert.Empty(t, env.Shutdown()) +} diff --git a/plugin/plugintest/api.go b/plugin/plugintest/api.go index b00542032..75174a9a6 100644 --- a/plugin/plugintest/api.go +++ b/plugin/plugintest/api.go @@ -30,6 +30,22 @@ func (m *API) LoadPluginConfiguration(dest interface{}) error { return ret.Error(0) } +func (m *API) RegisterCommand(command *model.Command) error { + ret := m.Called(command) + if f, ok := ret.Get(0).(func(*model.Command) error); ok { + return f(command) + } + return ret.Error(0) +} + +func (m *API) UnregisterCommand(teamId, trigger string) error { + ret := m.Called(teamId, trigger) + if f, ok := ret.Get(0).(func(string, string) error); ok { + return f(teamId, trigger) + } + return ret.Error(0) +} + func (m *API) CreateUser(user *model.User) (*model.User, *model.AppError) { ret := m.Called(user) if f, ok := ret.Get(0).(func(*model.User) (*model.User, *model.AppError)); ok { diff --git a/plugin/plugintest/hooks.go b/plugin/plugintest/hooks.go index 56d048d6a..9ea11a9fb 100644 --- a/plugin/plugintest/hooks.go +++ b/plugin/plugintest/hooks.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/mock" + "github.com/mattermost/mattermost-server/model" "github.com/mattermost/mattermost-server/plugin" ) @@ -18,7 +19,11 @@ type Hooks struct { var _ plugin.Hooks = (*Hooks)(nil) func (m *Hooks) OnActivate(api plugin.API) error { - return m.Called(api).Error(0) + ret := m.Called(api) + if f, ok := ret.Get(0).(func(plugin.API) error); ok { + return f(api) + } + return ret.Error(0) } func (m *Hooks) OnDeactivate() error { @@ -32,3 +37,13 @@ func (m *Hooks) OnConfigurationChange() error { func (m *Hooks) ServeHTTP(w http.ResponseWriter, r *http.Request) { m.Called(w, r) } + +func (m *Hooks) ExecuteCommand(args *model.CommandArgs) (*model.CommandResponse, *model.AppError) { + ret := m.Called(args) + if f, ok := ret.Get(0).(func(*model.CommandArgs) (*model.CommandResponse, *model.AppError)); ok { + return f(args) + } + resp, _ := ret.Get(0).(*model.CommandResponse) + err, _ := ret.Get(1).(*model.AppError) + return resp, err +} diff --git a/plugin/rpcplugin/api.go b/plugin/rpcplugin/api.go index 76c6e3039..5b5b11a62 100644 --- a/plugin/rpcplugin/api.go +++ b/plugin/rpcplugin/api.go @@ -32,6 +32,14 @@ func (api *LocalAPI) LoadPluginConfiguration(args struct{}, reply *[]byte) error return nil } +func (api *LocalAPI) RegisterCommand(args *model.Command, reply *APITeamReply) error { + return api.api.RegisterCommand(args) +} + +func (api *LocalAPI) UnregisterCommand(args *APIUnregisterCommandArgs, reply *APITeamReply) error { + return api.api.UnregisterCommand(args.TeamId, args.Trigger) +} + type APIErrorReply struct { Error *model.AppError } @@ -344,6 +352,22 @@ func (api *RemoteAPI) LoadPluginConfiguration(dest interface{}) error { return json.Unmarshal(config, dest) } +func (api *RemoteAPI) RegisterCommand(command *model.Command) error { + return api.client.Call("LocalAPI.RegisterCommand", command, nil) +} + +type APIUnregisterCommandArgs struct { + TeamId string + Trigger string +} + +func (api *RemoteAPI) UnregisterCommand(teamId, trigger string) error { + return api.client.Call("LocalAPI.UnregisterCommand", &APIUnregisterCommandArgs{ + TeamId: teamId, + Trigger: trigger, + }, nil) +} + func (api *RemoteAPI) CreateUser(user *model.User) (*model.User, *model.AppError) { var reply APIUserReply if err := api.client.Call("LocalAPI.CreateUser", user, &reply); err != nil { diff --git a/plugin/rpcplugin/api_test.go b/plugin/rpcplugin/api_test.go index f9e474d4a..145ec9005 100644 --- a/plugin/rpcplugin/api_test.go +++ b/plugin/rpcplugin/api_test.go @@ -2,6 +2,7 @@ package rpcplugin import ( "encoding/json" + "fmt" "io" "net/http" "testing" @@ -84,6 +85,16 @@ func TestAPI(t *testing.T) { assert.Equal(t, "foo", config.Foo) assert.Equal(t, "baz", config.Bar.Baz) + api.On("RegisterCommand", mock.AnythingOfType("*model.Command")).Return(fmt.Errorf("foo")).Once() + assert.Error(t, remote.RegisterCommand(&model.Command{})) + api.On("RegisterCommand", mock.AnythingOfType("*model.Command")).Return(nil).Once() + assert.NoError(t, remote.RegisterCommand(&model.Command{})) + + api.On("UnregisterCommand", "team", "trigger").Return(fmt.Errorf("foo")).Once() + assert.Error(t, remote.UnregisterCommand("team", "trigger")) + api.On("UnregisterCommand", "team", "trigger").Return(nil).Once() + assert.NoError(t, remote.UnregisterCommand("team", "trigger")) + api.On("CreateChannel", mock.AnythingOfType("*model.Channel")).Return(func(c *model.Channel) (*model.Channel, *model.AppError) { c.Id = "thechannelid" return c, nil diff --git a/plugin/rpcplugin/hooks.go b/plugin/rpcplugin/hooks.go index 22f26e22e..7b44d0de7 100644 --- a/plugin/rpcplugin/hooks.go +++ b/plugin/rpcplugin/hooks.go @@ -11,6 +11,7 @@ import ( "net/rpc" "reflect" + "github.com/mattermost/mattermost-server/model" "github.com/mattermost/mattermost-server/plugin" ) @@ -125,6 +126,20 @@ func (h *LocalHooks) ServeHTTP(args ServeHTTPArgs, reply *struct{}) error { return nil } +type HooksExecuteCommandReply struct { + Response *model.CommandResponse + Error *model.AppError +} + +func (h *LocalHooks) ExecuteCommand(args *model.CommandArgs, reply *HooksExecuteCommandReply) error { + if hook, ok := h.hooks.(interface { + ExecuteCommand(*model.CommandArgs) (*model.CommandResponse, *model.AppError) + }); ok { + reply.Response, reply.Error = hook.ExecuteCommand(args) + } + return nil +} + func ServeHooks(hooks interface{}, conn io.ReadWriteCloser, muxer *Muxer) { server := rpc.NewServer() server.Register(&LocalHooks{ @@ -141,6 +156,7 @@ const ( remoteOnDeactivate = 1 remoteServeHTTP = 2 remoteOnConfigurationChange = 3 + remoteExecuteCommand = 4 maxRemoteHookCount = iota ) @@ -225,6 +241,17 @@ func (h *RemoteHooks) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +func (h *RemoteHooks) ExecuteCommand(args *model.CommandArgs) (*model.CommandResponse, *model.AppError) { + if !h.implemented[remoteExecuteCommand] { + return nil, model.NewAppError("RemoteHooks.ExecuteCommand", "plugin.rpcplugin.invocation.error", nil, "err=ExecuteCommand hook not implemented", http.StatusInternalServerError) + } + var reply HooksExecuteCommandReply + if err := h.client.Call("LocalHooks.ExecuteCommand", args, &reply); err != nil { + return nil, model.NewAppError("RemoteHooks.ExecuteCommand", "plugin.rpcplugin.invocation.error", nil, "err="+err.Error(), http.StatusInternalServerError) + } + return reply.Response, reply.Error +} + func (h *RemoteHooks) Close() error { if h.apiCloser != nil { h.apiCloser.Close() @@ -253,6 +280,8 @@ func ConnectHooks(conn io.ReadWriteCloser, muxer *Muxer) (*RemoteHooks, error) { remote.implemented[remoteOnConfigurationChange] = true case "ServeHTTP": remote.implemented[remoteServeHTTP] = true + case "ExecuteCommand": + remote.implemented[remoteExecuteCommand] = true } } return remote, nil diff --git a/plugin/rpcplugin/hooks_test.go b/plugin/rpcplugin/hooks_test.go index 37c529510..116038dae 100644 --- a/plugin/rpcplugin/hooks_test.go +++ b/plugin/rpcplugin/hooks_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/mattermost/mattermost-server/model" "github.com/mattermost/mattermost-server/plugin" "github.com/mattermost/mattermost-server/plugin/plugintest" ) @@ -79,6 +80,17 @@ func TestHooks(t *testing.T) { body, err := ioutil.ReadAll(resp.Body) assert.NoError(t, err) assert.Equal(t, "bar", string(body)) + + hooks.On("ExecuteCommand", &model.CommandArgs{ + Command: "/foo", + }).Return(&model.CommandResponse{ + Text: "bar", + }, nil) + commandResponse, appErr := hooks.ExecuteCommand(&model.CommandArgs{ + Command: "/foo", + }) + assert.Equal(t, "bar", commandResponse.Text) + assert.Nil(t, appErr) })) } diff --git a/store/sqlstore/channel_member_history_store.go b/store/sqlstore/channel_member_history_store.go index aa5037d32..182f37ce9 100644 --- a/store/sqlstore/channel_member_history_store.go +++ b/store/sqlstore/channel_member_history_store.go @@ -6,6 +6,8 @@ package sqlstore import ( "net/http" + "database/sql" + l4g "github.com/alecthomas/log4go" "github.com/mattermost/mattermost-server/model" "github.com/mattermost/mattermost-server/store" @@ -65,7 +67,47 @@ func (s SqlChannelMemberHistoryStore) LogLeaveEvent(userId string, channelId str func (s SqlChannelMemberHistoryStore) GetUsersInChannelDuring(startTime int64, endTime int64, channelId string) store.StoreChannel { return store.Do(func(result *store.StoreResult) { - query := ` + if useChannelMemberHistory, err := s.hasDataAtOrBefore(startTime); err != nil { + result.Err = model.NewAppError("SqlChannelMemberHistoryStore.GetUsersInChannelAt", "store.sql_channel_member_history.get_users_in_channel_during.app_error", nil, err.Error(), http.StatusInternalServerError) + } else if useChannelMemberHistory { + // the export period starts after the ChannelMemberHistory table was first introduced, so we can use the + // data from it for our export + if channelMemberHistories, err := s.getFromChannelMemberHistoryTable(startTime, endTime, channelId); err != nil { + result.Err = model.NewAppError("SqlChannelMemberHistoryStore.GetUsersInChannelAt", "store.sql_channel_member_history.get_users_in_channel_during.app_error", nil, err.Error(), http.StatusInternalServerError) + } else { + result.Data = channelMemberHistories + } + } else { + // the export period starts before the ChannelMemberHistory table was introduced, so we need to fake the + // data by assuming that anybody who has ever joined the channel in question was present during the export period. + // this may not always be true, but it's better than saying that somebody wasn't there when they were + if channelMemberHistories, err := s.getFromChannelMembersTable(startTime, endTime, channelId); err != nil { + result.Err = model.NewAppError("SqlChannelMemberHistoryStore.GetUsersInChannelAt", "store.sql_channel_member_history.get_users_in_channel_during.app_error", nil, err.Error(), http.StatusInternalServerError) + } else { + result.Data = channelMemberHistories + } + } + }) +} + +func (s SqlChannelMemberHistoryStore) hasDataAtOrBefore(time int64) (bool, error) { + type NullableCountResult struct { + Min sql.NullInt64 + } + var result NullableCountResult + query := "SELECT MIN(JoinTime) AS Min FROM ChannelMemberHistory" + if err := s.GetReplica().SelectOne(&result, query); err != nil { + return false, err + } else if result.Min.Valid { + return result.Min.Int64 <= time, nil + } else { + // if the result was null, there are no rows in the table, so there is no data from before + return false, nil + } +} + +func (s SqlChannelMemberHistoryStore) getFromChannelMemberHistoryTable(startTime int64, endTime int64, channelId string) ([]*model.ChannelMemberHistory, error) { + query := ` SELECT cmh.*, u.Email @@ -76,14 +118,37 @@ func (s SqlChannelMemberHistoryStore) GetUsersInChannelDuring(startTime int64, e AND (cmh.LeaveTime IS NULL OR cmh.LeaveTime >= :StartTime) ORDER BY cmh.JoinTime ASC` - params := map[string]interface{}{"ChannelId": channelId, "StartTime": startTime, "EndTime": endTime} - var histories []*model.ChannelMemberHistory - if _, err := s.GetReplica().Select(&histories, query, params); err != nil { - result.Err = model.NewAppError("SqlChannelMemberHistoryStore.GetUsersInChannelAt", "store.sql_channel_member_history.get_users_in_channel_during.app_error", params, err.Error(), http.StatusInternalServerError) - } else { - result.Data = histories + params := map[string]interface{}{"ChannelId": channelId, "StartTime": startTime, "EndTime": endTime} + var histories []*model.ChannelMemberHistory + if _, err := s.GetReplica().Select(&histories, query, params); err != nil { + return nil, err + } else { + return histories, nil + } +} + +func (s SqlChannelMemberHistoryStore) getFromChannelMembersTable(startTime int64, endTime int64, channelId string) ([]*model.ChannelMemberHistory, error) { + query := ` + SELECT DISTINCT + ch.ChannelId, + ch.UserId, + u.email + FROM ChannelMembers AS ch + INNER JOIN Users AS u ON ch.UserId = u.id + WHERE ch.ChannelId = :ChannelId` + + params := map[string]interface{}{"ChannelId": channelId} + var histories []*model.ChannelMemberHistory + if _, err := s.GetReplica().Select(&histories, query, params); err != nil { + return nil, err + } else { + // we have to fill in the join/leave times, because that data doesn't exist in the channel members table + for _, channelMemberHistory := range histories { + channelMemberHistory.JoinTime = startTime + channelMemberHistory.LeaveTime = model.NewInt64(endTime) } - }) + return histories, nil + } } func (s SqlChannelMemberHistoryStore) PermanentDeleteBatch(endTime int64, limit int64) store.StoreChannel { @@ -112,7 +177,6 @@ func (s SqlChannelMemberHistoryStore) PermanentDeleteBatch(endTime int64, limit } else { if rowsAffected, err1 := sqlResult.RowsAffected(); err1 != nil { result.Err = model.NewAppError("SqlChannelMemberHistoryStore.PermanentDeleteBatchForChannel", "store.sql_channel_member_history.permanent_delete_batch.app_error", params, err.Error(), http.StatusInternalServerError) - result.Data = int64(0) } else { result.Data = rowsAffected } diff --git a/store/sqlstore/post_store.go b/store/sqlstore/post_store.go index 2fa8f2403..f3305903b 100644 --- a/store/sqlstore/post_store.go +++ b/store/sqlstore/post_store.go @@ -696,7 +696,7 @@ func (s SqlPostStore) getParentsPosts(channelId string, offset int, limit int) s ORDER BY CreateAt`, map[string]interface{}{"ChannelId1": channelId, "Offset": offset, "Limit": limit, "ChannelId2": channelId}) if err != nil { - result.Err = model.NewAppError("SqlPostStore.GetLinearPosts", "store.sql_post.get_parents_posts.app_error", nil, "channelId="+channelId+err.Error(), http.StatusInternalServerError) + result.Err = model.NewAppError("SqlPostStore.GetLinearPosts", "store.sql_post.get_parents_posts.app_error", nil, "channelId="+channelId+" err="+err.Error(), http.StatusInternalServerError) } else { result.Data = posts } @@ -1030,10 +1030,10 @@ func (s SqlPostStore) AnalyticsPostCount(teamId string, mustHaveFile bool, mustH func (s SqlPostStore) GetPostsCreatedAt(channelId string, time int64) store.StoreChannel { return store.Do(func(result *store.StoreResult) { - query := `SELECT * FROM Posts WHERE CreateAt = :CreateAt` + query := `SELECT * FROM Posts WHERE CreateAt = :CreateAt AND ChannelId = :ChannelId` var posts []*model.Post - _, err := s.GetReplica().Select(&posts, query, map[string]interface{}{"CreateAt": time}) + _, err := s.GetReplica().Select(&posts, query, map[string]interface{}{"CreateAt": time, "ChannelId": channelId}) if err != nil { result.Err = model.NewAppError("SqlPostStore.GetPostsCreatedAt", "store.sql_post.get_posts_created_att.app_error", nil, "channelId="+channelId+err.Error(), http.StatusInternalServerError) diff --git a/store/sqlstore/upgrade.go b/store/sqlstore/upgrade.go index 9bb3250d6..4d6985033 100644 --- a/store/sqlstore/upgrade.go +++ b/store/sqlstore/upgrade.go @@ -15,6 +15,7 @@ import ( ) const ( + VERSION_4_6_0 = "4.6.0" VERSION_4_5_0 = "4.5.0" VERSION_4_4_0 = "4.4.0" VERSION_4_3_0 = "4.3.0" @@ -60,6 +61,7 @@ func UpgradeDatabase(sqlStore SqlStore) { UpgradeDatabaseToVersion43(sqlStore) UpgradeDatabaseToVersion44(sqlStore) UpgradeDatabaseToVersion45(sqlStore) + UpgradeDatabaseToVersion46(sqlStore) // If the SchemaVersion is empty this this is the first time it has ran // so lets set it to the current version. @@ -322,6 +324,14 @@ func UpgradeDatabaseToVersion44(sqlStore SqlStore) { } } +func UpgradeDatabaseToVersion46(sqlStore SqlStore) { + //TODO: Uncomment folowing when version 4.6 is released + //if shouldPerformUpgrade(sqlStore, VERSION_4_5_0, VERSION_4_6_0) { + + //saveSchemaVersion(sqlStore, VERSION_4_6_0) + //} +} + func UpgradeDatabaseToVersion45(sqlStore SqlStore) { if shouldPerformUpgrade(sqlStore, VERSION_4_4_0, VERSION_4_5_0) { saveSchemaVersion(sqlStore, VERSION_4_5_0) diff --git a/store/storetest/channel_member_history_store.go b/store/storetest/channel_member_history_store.go index 0be92c6e0..6fe73478c 100644 --- a/store/storetest/channel_member_history_store.go +++ b/store/storetest/channel_member_history_store.go @@ -14,10 +14,11 @@ import ( ) func TestChannelMemberHistoryStore(t *testing.T, ss store.Store) { - t.Run("Log Join Event", func(t *testing.T) { testLogJoinEvent(t, ss) }) - t.Run("Log Leave Event", func(t *testing.T) { testLogLeaveEvent(t, ss) }) - t.Run("Get Users In Channel At Time", func(t *testing.T) { testGetUsersInChannelAt(t, ss) }) - t.Run("Purge History", func(t *testing.T) { testPermanentDeleteBatch(t, ss) }) + t.Run("TestLogJoinEvent", func(t *testing.T) { testLogJoinEvent(t, ss) }) + t.Run("TestLogLeaveEvent", func(t *testing.T) { testLogLeaveEvent(t, ss) }) + t.Run("TestGetUsersInChannelAtChannelMemberHistory", func(t *testing.T) { testGetUsersInChannelAtChannelMemberHistory(t, ss) }) + t.Run("TestGetUsersInChannelAtChannelMembers", func(t *testing.T) { testGetUsersInChannelAtChannelMembers(t, ss) }) + t.Run("TestPermanentDeleteBatch", func(t *testing.T) { testPermanentDeleteBatch(t, ss) }) } func testLogJoinEvent(t *testing.T, ss store.Store) { @@ -67,7 +68,7 @@ func testLogLeaveEvent(t *testing.T, ss store.Store) { assert.Nil(t, result.Err) } -func testGetUsersInChannelAt(t *testing.T, ss store.Store) { +func testGetUsersInChannelAtChannelMemberHistory(t *testing.T, ss store.Store) { // create a test channel channel := model.Channel{ TeamId: model.NewId(), @@ -84,17 +85,25 @@ func testGetUsersInChannelAt(t *testing.T, ss store.Store) { } user = *store.Must(ss.User().Save(&user)).(*model.User) - // log a join event - leaveTime := model.GetMillis() + // the user was previously in the channel a long time ago, before the export period starts + // the existence of this record makes it look like the MessageExport feature has been active for awhile, and prevents + // us from looking in the ChannelMembers table for data that isn't found in the ChannelMemberHistory table + leaveTime := model.GetMillis() - 20000 joinTime := leaveTime - 10000 store.Must(ss.ChannelMemberHistory().LogJoinEvent(user.Id, channel.Id, joinTime)) + store.Must(ss.ChannelMemberHistory().LogLeaveEvent(user.Id, channel.Id, leaveTime)) + + // log a join event + leaveTime = model.GetMillis() + joinTime = leaveTime - 10000 + store.Must(ss.ChannelMemberHistory().LogJoinEvent(user.Id, channel.Id, joinTime)) - // case 1: both start and end before join time + // case 1: user joins and leaves the channel before the export period begins channelMembers := store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-500, joinTime-100, channel.Id)).([]*model.ChannelMemberHistory) assert.Len(t, channelMembers, 0) - // case 2: start before join time, no leave time - channelMembers = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-100, joinTime+100, channel.Id)).([]*model.ChannelMemberHistory) + // case 2: user joins the channel after the export period begins, but has not yet left the channel when the export period ends + channelMembers = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-100, joinTime+500, channel.Id)).([]*model.ChannelMemberHistory) assert.Len(t, channelMembers, 1) assert.Equal(t, channel.Id, channelMembers[0].ChannelId) assert.Equal(t, user.Id, channelMembers[0].UserId) @@ -102,7 +111,7 @@ func testGetUsersInChannelAt(t *testing.T, ss store.Store) { assert.Equal(t, joinTime, channelMembers[0].JoinTime) assert.Nil(t, channelMembers[0].LeaveTime) - // case 3: start after join time, no leave time + // case 3: user joins the channel before the export period begins, but has not yet left the channel when the export period ends channelMembers = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime+100, joinTime+500, channel.Id)).([]*model.ChannelMemberHistory) assert.Len(t, channelMembers, 1) assert.Equal(t, channel.Id, channelMembers[0].ChannelId) @@ -114,7 +123,7 @@ func testGetUsersInChannelAt(t *testing.T, ss store.Store) { // add a leave time for the user store.Must(ss.ChannelMemberHistory().LogLeaveEvent(user.Id, channel.Id, leaveTime)) - // case 4: start after join time, end before leave time + // case 4: user joins the channel before the export period begins, but has not yet left the channel when the export period ends channelMembers = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime+100, leaveTime-100, channel.Id)).([]*model.ChannelMemberHistory) assert.Len(t, channelMembers, 1) assert.Equal(t, channel.Id, channelMembers[0].ChannelId) @@ -123,7 +132,7 @@ func testGetUsersInChannelAt(t *testing.T, ss store.Store) { assert.Equal(t, joinTime, channelMembers[0].JoinTime) assert.Equal(t, leaveTime, *channelMembers[0].LeaveTime) - // case 5: start before join time, end after leave time + // case 5: user joins the channel after the export period begins, and leaves the channel before the export period ends channelMembers = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-100, leaveTime+100, channel.Id)).([]*model.ChannelMemberHistory) assert.Len(t, channelMembers, 1) assert.Equal(t, channel.Id, channelMembers[0].ChannelId) @@ -132,11 +141,106 @@ func testGetUsersInChannelAt(t *testing.T, ss store.Store) { assert.Equal(t, joinTime, channelMembers[0].JoinTime) assert.Equal(t, leaveTime, *channelMembers[0].LeaveTime) - // case 6: start and end after leave time + // case 6: user has joined and left the channel long before the export period begins channelMembers = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(leaveTime+100, leaveTime+200, channel.Id)).([]*model.ChannelMemberHistory) assert.Len(t, channelMembers, 0) } +func testGetUsersInChannelAtChannelMembers(t *testing.T, ss store.Store) { + // create a test channel + channel := model.Channel{ + TeamId: model.NewId(), + DisplayName: "Display " + model.NewId(), + Name: "zz" + model.NewId() + "b", + Type: model.CHANNEL_OPEN, + } + channel = *store.Must(ss.Channel().Save(&channel, -1)).(*model.Channel) + + // and a test user + user := model.User{ + Email: model.NewId() + "@mattermost.com", + Nickname: model.NewId(), + } + user = *store.Must(ss.User().Save(&user)).(*model.User) + + // clear any existing ChannelMemberHistory data that might interfere with our test + var tableDataTruncated = false + for !tableDataTruncated { + if result := <-ss.ChannelMemberHistory().PermanentDeleteBatch(model.GetMillis(), 1000); result.Err != nil { + assert.Fail(t, "Failed to truncate ChannelMemberHistory contents", result.Err.Error()) + } else { + tableDataTruncated = result.Data.(int64) == int64(0) + } + } + + // in this test, we're pretending that Message Export was not activated during the export period, so there's no data + // available in the ChannelMemberHistory table. Instead, we'll fall back to the ChannelMembers table for a rough approximation + joinTime := int64(1000) + leaveTime := joinTime + 5000 + store.Must(ss.Channel().SaveMember(&model.ChannelMember{ + ChannelId: channel.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + })) + + // in every single case, the user will be included in the export, because ChannelMembers says they were in the channel at some point in + // the past, even though the time that they were actually in the channel doesn't necessarily overlap with the export period + + // case 1: user joins and leaves the channel before the export period begins + channelMembers := store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-500, joinTime-100, channel.Id)).([]*model.ChannelMemberHistory) + assert.Len(t, channelMembers, 1) + assert.Equal(t, channel.Id, channelMembers[0].ChannelId) + assert.Equal(t, user.Id, channelMembers[0].UserId) + assert.Equal(t, user.Email, channelMembers[0].UserEmail) + assert.Equal(t, joinTime-500, channelMembers[0].JoinTime) + assert.Equal(t, joinTime-100, *channelMembers[0].LeaveTime) + + // case 2: user joins the channel after the export period begins, but has not yet left the channel when the export period ends + channelMembers = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-100, joinTime+500, channel.Id)).([]*model.ChannelMemberHistory) + assert.Len(t, channelMembers, 1) + assert.Equal(t, channel.Id, channelMembers[0].ChannelId) + assert.Equal(t, user.Id, channelMembers[0].UserId) + assert.Equal(t, user.Email, channelMembers[0].UserEmail) + assert.Equal(t, joinTime-100, channelMembers[0].JoinTime) + assert.Equal(t, joinTime+500, *channelMembers[0].LeaveTime) + + // case 3: user joins the channel before the export period begins, but has not yet left the channel when the export period ends + channelMembers = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime+100, joinTime+500, channel.Id)).([]*model.ChannelMemberHistory) + assert.Len(t, channelMembers, 1) + assert.Equal(t, channel.Id, channelMembers[0].ChannelId) + assert.Equal(t, user.Id, channelMembers[0].UserId) + assert.Equal(t, user.Email, channelMembers[0].UserEmail) + assert.Equal(t, joinTime+100, channelMembers[0].JoinTime) + assert.Equal(t, joinTime+500, *channelMembers[0].LeaveTime) + + // case 4: user joins the channel before the export period begins, but has not yet left the channel when the export period ends + channelMembers = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime+100, leaveTime-100, channel.Id)).([]*model.ChannelMemberHistory) + assert.Len(t, channelMembers, 1) + assert.Equal(t, channel.Id, channelMembers[0].ChannelId) + assert.Equal(t, user.Id, channelMembers[0].UserId) + assert.Equal(t, user.Email, channelMembers[0].UserEmail) + assert.Equal(t, joinTime+100, channelMembers[0].JoinTime) + assert.Equal(t, leaveTime-100, *channelMembers[0].LeaveTime) + + // case 5: user joins the channel after the export period begins, and leaves the channel before the export period ends + channelMembers = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-100, leaveTime+100, channel.Id)).([]*model.ChannelMemberHistory) + assert.Len(t, channelMembers, 1) + assert.Equal(t, channel.Id, channelMembers[0].ChannelId) + assert.Equal(t, user.Id, channelMembers[0].UserId) + assert.Equal(t, user.Email, channelMembers[0].UserEmail) + assert.Equal(t, joinTime-100, channelMembers[0].JoinTime) + assert.Equal(t, leaveTime+100, *channelMembers[0].LeaveTime) + + // case 6: user has joined and left the channel long before the export period begins + channelMembers = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(leaveTime+100, leaveTime+200, channel.Id)).([]*model.ChannelMemberHistory) + assert.Len(t, channelMembers, 1) + assert.Equal(t, channel.Id, channelMembers[0].ChannelId) + assert.Equal(t, user.Id, channelMembers[0].UserId) + assert.Equal(t, user.Email, channelMembers[0].UserEmail) + assert.Equal(t, leaveTime+100, channelMembers[0].JoinTime) + assert.Equal(t, leaveTime+200, *channelMembers[0].LeaveTime) +} + func testPermanentDeleteBatch(t *testing.T, ss store.Store) { // create a test channel channel := model.Channel{ diff --git a/store/storetest/post_store.go b/store/storetest/post_store.go index afa8ae293..4deb7f8d4 100644 --- a/store/storetest/post_store.go +++ b/store/storetest/post_store.go @@ -1408,7 +1408,7 @@ func testPostStoreGetFlaggedPostsForChannel(t *testing.T, ss store.Store) { } func testPostStoreGetPostsCreatedAt(t *testing.T, ss store.Store) { - createTime := model.GetMillis() + createTime := model.GetMillis() + 1 o0 := &model.Post{} o0.ChannelId = model.NewId() @@ -1418,12 +1418,11 @@ func testPostStoreGetPostsCreatedAt(t *testing.T, ss store.Store) { o0 = (<-ss.Post().Save(o0)).Data.(*model.Post) o1 := &model.Post{} - o1.ChannelId = o0.Id + o1.ChannelId = o0.ChannelId o1.UserId = model.NewId() o1.Message = "zz" + model.NewId() + "b" - o0.CreateAt = createTime + o1.CreateAt = createTime o1 = (<-ss.Post().Save(o1)).Data.(*model.Post) - time.Sleep(2 * time.Millisecond) o2 := &model.Post{} o2.ChannelId = o1.ChannelId @@ -1431,8 +1430,8 @@ func testPostStoreGetPostsCreatedAt(t *testing.T, ss store.Store) { o2.Message = "zz" + model.NewId() + "b" o2.ParentId = o1.Id o2.RootId = o1.Id + o2.CreateAt = createTime + 1 o2 = (<-ss.Post().Save(o2)).Data.(*model.Post) - time.Sleep(2 * time.Millisecond) o3 := &model.Post{} o3.ChannelId = model.NewId() @@ -1440,13 +1439,9 @@ func testPostStoreGetPostsCreatedAt(t *testing.T, ss store.Store) { o3.Message = "zz" + model.NewId() + "b" o3.CreateAt = createTime o3 = (<-ss.Post().Save(o3)).Data.(*model.Post) - time.Sleep(2 * time.Millisecond) r1 := (<-ss.Post().GetPostsCreatedAt(o1.ChannelId, createTime)).Data.([]*model.Post) - - if len(r1) != 2 { - t.Fatalf("Got the wrong number of posts.") - } + assert.Equal(t, 2, len(r1)) } func testPostStoreOverwrite(t *testing.T, ss store.Store) { diff --git a/utils/config.go b/utils/config.go index 929e39346..180bd7fea 100644 --- a/utils/config.go +++ b/utils/config.go @@ -489,6 +489,7 @@ func getClientConfig(c *model.Config) map[string]string { props["PostEditTimeLimit"] = fmt.Sprintf("%v", *c.ServiceSettings.PostEditTimeLimit) props["CloseUnusedDirectMessages"] = strconv.FormatBool(*c.ServiceSettings.CloseUnusedDirectMessages) props["EnablePreviewFeatures"] = strconv.FormatBool(*c.ServiceSettings.EnablePreviewFeatures) + props["EnableTutorial"] = strconv.FormatBool(*c.ServiceSettings.EnableTutorial) props["SendEmailNotifications"] = strconv.FormatBool(c.EmailSettings.SendEmailNotifications) props["SendPushNotifications"] = strconv.FormatBool(*c.EmailSettings.SendPushNotifications) |