summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--api4/params.go17
-rw-r--r--api4/plugin_test.go2
-rw-r--r--api4/reaction_test.go15
-rw-r--r--api4/system.go2
-rw-r--r--api4/system_test.go16
-rw-r--r--api4/user.go12
-rw-r--r--api4/user_test.go11
-rw-r--r--app/admin.go55
-rw-r--r--app/app.go4
-rw-r--r--app/apptestlib.go73
-rw-r--r--app/channel.go23
-rw-r--r--app/command.go24
-rw-r--r--app/notification.go2
-rw-r--r--app/notification_test.go16
-rw-r--r--app/plugin.go147
-rw-r--r--app/plugin_api.go9
-rw-r--r--app/plugin_test.go97
-rw-r--r--cmd/platform/server.go4
-rw-r--r--cmd/platform/user.go4
-rw-r--r--config/default.json3
-rw-r--r--i18n/en.json16
-rw-r--r--model/client4.go2
-rw-r--r--model/config.go5
-rw-r--r--model/post.go29
-rw-r--r--plugin/api.go7
-rw-r--r--plugin/hooks.go6
-rw-r--r--plugin/pluginenv/environment.go64
-rw-r--r--plugin/pluginenv/environment_test.go48
-rw-r--r--plugin/plugintest/api.go16
-rw-r--r--plugin/plugintest/hooks.go17
-rw-r--r--plugin/rpcplugin/api.go24
-rw-r--r--plugin/rpcplugin/api_test.go11
-rw-r--r--plugin/rpcplugin/hooks.go29
-rw-r--r--plugin/rpcplugin/hooks_test.go12
-rw-r--r--store/sqlstore/channel_member_history_store.go82
-rw-r--r--store/sqlstore/post_store.go6
-rw-r--r--store/sqlstore/upgrade.go10
-rw-r--r--store/storetest/channel_member_history_store.go132
-rw-r--r--store/storetest/post_store.go15
-rw-r--r--utils/config.go1
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)