diff options
46 files changed, 1586 insertions, 113 deletions
diff --git a/api4/status.go b/api4/status.go index 59909e295..627ddaca6 100644 --- a/api4/status.go +++ b/api4/status.go @@ -71,6 +71,11 @@ func updateUserStatus(c *Context, w http.ResponseWriter, r *http.Request) { return } + currentStatus, err := c.App.GetStatus(c.Params.UserId) + if err == nil && currentStatus.Status == model.STATUS_OUT_OF_OFFICE && status.Status != model.STATUS_OUT_OF_OFFICE { + c.App.DisableAutoResponder(c.Params.UserId, c.IsSystemAdmin()) + } + switch status.Status { case "online": c.App.SetStatusOnline(c.Params.UserId, "", true) diff --git a/api4/system.go b/api4/system.go index b34f2af6b..c307a39b7 100644 --- a/api4/system.go +++ b/api4/system.go @@ -23,6 +23,7 @@ func (api *API) InitSystem() { api.BaseRoutes.ApiRoot.Handle("/config", api.ApiSessionRequired(updateConfig)).Methods("PUT") api.BaseRoutes.ApiRoot.Handle("/config/reload", api.ApiSessionRequired(configReload)).Methods("POST") api.BaseRoutes.ApiRoot.Handle("/config/client", api.ApiHandler(getClientConfig)).Methods("GET") + api.BaseRoutes.ApiRoot.Handle("/config/environment", api.ApiSessionRequired(getEnvironmentConfig)).Methods("GET") api.BaseRoutes.ApiRoot.Handle("/license", api.ApiSessionRequired(addLicense)).Methods("POST") api.BaseRoutes.ApiRoot.Handle("/license", api.ApiSessionRequired(removeLicense)).Methods("DELETE") @@ -251,6 +252,18 @@ func getClientConfig(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(model.MapToJson(c.App.ClientConfigWithComputed()))) } +func getEnvironmentConfig(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(c.Session, model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + envConfig := c.App.GetEnvironmentConfig() + + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Write([]byte(model.StringInterfaceToJson(envConfig))) +} + func getClientLicense(c *Context, w http.ResponseWriter, r *http.Request) { format := r.URL.Query().Get("format") diff --git a/api4/system_test.go b/api4/system_test.go index bb3790d4b..f74d91563 100644 --- a/api4/system_test.go +++ b/api4/system_test.go @@ -161,6 +161,70 @@ func TestUpdateConfig(t *testing.T) { }) } +func TestGetEnvironmentConfig(t *testing.T) { + os.Setenv("MM_SERVICESETTINGS_SITEURL", "http://example.mattermost.com") + os.Setenv("MM_SERVICESETTINGS_ENABLECUSTOMEMOJI", "true") + defer os.Unsetenv("MM_SERVICESETTINGS_SITEURL") + + th := Setup().InitBasic().InitSystemAdmin() + defer th.TearDown() + + t.Run("as system admin", func(t *testing.T) { + SystemAdminClient := th.SystemAdminClient + + envConfig, resp := SystemAdminClient.GetEnvironmentConfig() + CheckNoError(t, resp) + + if serviceSettings, ok := envConfig["ServiceSettings"]; !ok { + t.Fatal("should've returned ServiceSettings") + } else if serviceSettingsAsMap, ok := serviceSettings.(map[string]interface{}); !ok { + t.Fatal("should've returned ServiceSettings as a map") + } else { + if siteURL, ok := serviceSettingsAsMap["SiteURL"]; !ok { + t.Fatal("should've returned ServiceSettings.SiteURL") + } else if siteURLAsBool, ok := siteURL.(bool); !ok { + t.Fatal("should've returned ServiceSettings.SiteURL as a boolean") + } else if !siteURLAsBool { + t.Fatal("should've returned ServiceSettings.SiteURL as true") + } + + if enableCustomEmoji, ok := serviceSettingsAsMap["EnableCustomEmoji"]; !ok { + t.Fatal("should've returned ServiceSettings.EnableCustomEmoji") + } else if enableCustomEmojiAsBool, ok := enableCustomEmoji.(bool); !ok { + t.Fatal("should've returned ServiceSettings.EnableCustomEmoji as a boolean") + } else if !enableCustomEmojiAsBool { + t.Fatal("should've returned ServiceSettings.EnableCustomEmoji as true") + } + } + + if _, ok := envConfig["TeamSettings"]; ok { + t.Fatal("should not have returned TeamSettings") + } + }) + + t.Run("as team admin", func(t *testing.T) { + TeamAdminClient := th.CreateClient() + th.LoginTeamAdminWithClient(TeamAdminClient) + + _, resp := TeamAdminClient.GetEnvironmentConfig() + CheckForbiddenStatus(t, resp) + }) + + t.Run("as regular user", func(t *testing.T) { + Client := th.Client + + _, resp := Client.GetEnvironmentConfig() + CheckForbiddenStatus(t, resp) + }) + + t.Run("as not-regular user", func(t *testing.T) { + Client := th.CreateClient() + + _, resp := Client.GetEnvironmentConfig() + CheckUnauthorizedStatus(t, resp) + }) +} + func TestGetOldClientConfig(t *testing.T) { th := Setup().InitBasic().InitSystemAdmin() defer th.TearDown() diff --git a/api4/user.go b/api4/user.go index 8f8f08c75..9aa709db5 100644 --- a/api4/user.go +++ b/api4/user.go @@ -199,7 +199,8 @@ func getProfileImage(c *Context, w http.ResponseWriter, r *http.Request) { return } else { if len(users) == 0 { - c.Err = err + c.Err = model.NewAppError("getProfileImage", "api.user.get_profile_image.not_found.app_error", nil, "", http.StatusNotFound) + return } user := users[0] @@ -589,8 +590,13 @@ func patchUser(c *Context, w http.ResponseWriter, r *http.Request) { return } + ouser, err := c.App.GetUser(c.Params.UserId) + if err != nil { + c.SetInvalidParam("user_id") + return + } + if c.Session.IsOAuth && patch.Email != nil { - ouser, err := c.App.GetUser(c.Params.UserId) if err != nil { c.Err = err return @@ -607,6 +613,7 @@ func patchUser(c *Context, w http.ResponseWriter, r *http.Request) { c.Err = err return } else { + c.App.SetAutoResponderStatus(ruser, ouser.NotifyProps) c.LogAudit("") w.Write([]byte(ruser.ToJson())) } diff --git a/api4/user_test.go b/api4/user_test.go index 359756aeb..27219726b 100644 --- a/api4/user_test.go +++ b/api4/user_test.go @@ -834,6 +834,9 @@ func TestGetProfileImage(t *testing.T) { _, resp = Client.GetProfileImage("junk", "") CheckBadRequestStatus(t, resp) + _, resp = Client.GetProfileImage(model.NewId(), "") + CheckNotFoundStatus(t, resp) + Client.Logout() _, resp = Client.GetProfileImage(user.Id, "") CheckUnauthorizedStatus(t, resp) diff --git a/app/admin.go b/app/admin.go index 22928390e..60b71505a 100644 --- a/app/admin.go +++ b/app/admin.go @@ -156,6 +156,10 @@ func (a *App) GetConfig() *model.Config { return cfg } +func (a *App) GetEnvironmentConfig() map[string]interface{} { + return a.EnvironmentConfig() +} + func (a *App) SaveConfig(cfg *model.Config, sendConfigChangeClusterMessage bool) *model.AppError { oldCfg := a.Config() cfg.SetDefaults() @@ -239,7 +243,7 @@ func (a *App) TestEmail(userId string, cfg *model.Config) *model.AppError { T := utils.GetUserTranslations(user.Locale) license := a.License() if err := utils.SendMailUsingConfig(user.Email, T("api.admin.test_email.subject"), T("api.admin.test_email.body"), cfg, license != nil && *license.Features.Compliance); err != nil { - return err + return model.NewAppError("testEmail", "app.admin.test_email.failure", map[string]interface{}{"Error": err.Error()}, "", http.StatusInternalServerError) } } diff --git a/app/app.go b/app/app.go index 27227d271..43f598f79 100644 --- a/app/app.go +++ b/app/app.go @@ -59,6 +59,7 @@ type App struct { Saml einterfaces.SamlInterface config atomic.Value + envConfig map[string]interface{} configFile string configListeners map[string]func(*model.Config, *model.Config) diff --git a/app/auto_responder.go b/app/auto_responder.go new file mode 100644 index 000000000..23402ecd4 --- /dev/null +++ b/app/auto_responder.go @@ -0,0 +1,70 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + l4g "github.com/alecthomas/log4go" + + "github.com/mattermost/mattermost-server/model" +) + +func (a *App) SendAutoResponse(channel *model.Channel, receiver *model.User, rootId string) { + if receiver == nil || receiver.NotifyProps == nil { + return + } + + active := receiver.NotifyProps["auto_responder_active"] == "true" + message := receiver.NotifyProps["auto_responder_message"] + + if active && message != "" { + autoResponderPost := &model.Post{ + ChannelId: channel.Id, + Message: message, + RootId: rootId, + ParentId: rootId, + Type: model.POST_AUTO_RESPONDER, + UserId: receiver.Id, + } + + if _, err := a.CreatePost(autoResponderPost, channel, false); err != nil { + l4g.Error(err.Error()) + } + } +} + +func (a *App) SetAutoResponderStatus(user *model.User, oldNotifyProps model.StringMap) { + active := user.NotifyProps["auto_responder_active"] == "true" + oldActive := oldNotifyProps["auto_responder_active"] == "true" + + autoResponderEnabled := !oldActive && active + autoResponderDisabled := oldActive && !active + + if autoResponderEnabled { + a.SetStatusOutOfOffice(user.Id) + } else if autoResponderDisabled { + a.SetStatusOnline(user.Id, "", true) + } +} + +func (a *App) DisableAutoResponder(userId string, asAdmin bool) *model.AppError { + user, err := a.GetUser(userId) + if err != nil { + return err + } + + active := user.NotifyProps["auto_responder_active"] == "true" + + if active { + patch := &model.UserPatch{} + patch.NotifyProps = user.NotifyProps + patch.NotifyProps["auto_responder_active"] = "false" + + _, err := a.PatchUser(userId, patch, asAdmin) + if err != nil { + return err + } + } + + return nil +} diff --git a/app/auto_responder_test.go b/app/auto_responder_test.go new file mode 100644 index 000000000..65b466c92 --- /dev/null +++ b/app/auto_responder_test.go @@ -0,0 +1,160 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "testing" + + "github.com/mattermost/mattermost-server/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSetAutoResponderStatus(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + user := th.CreateUser() + defer th.App.PermanentDeleteUser(user) + + th.App.SetStatusOnline(user.Id, "", true) + + patch := &model.UserPatch{} + patch.NotifyProps = make(map[string]string) + patch.NotifyProps["auto_responder_active"] = "true" + patch.NotifyProps["auto_responder_message"] = "Hello, I'm unavailable today." + + userUpdated1, _ := th.App.PatchUser(user.Id, patch, true) + + // autoResponder is enabled, status should be OOO + th.App.SetAutoResponderStatus(userUpdated1, user.NotifyProps) + + status, err := th.App.GetStatus(userUpdated1.Id) + require.Nil(t, err) + assert.Equal(t, model.STATUS_OUT_OF_OFFICE, status.Status) + + patch2 := &model.UserPatch{} + patch2.NotifyProps = make(map[string]string) + patch2.NotifyProps["auto_responder_active"] = "false" + patch2.NotifyProps["auto_responder_message"] = "Hello, I'm unavailable today." + + userUpdated2, _ := th.App.PatchUser(user.Id, patch2, true) + + // autoResponder is disabled, status should be ONLINE + th.App.SetAutoResponderStatus(userUpdated2, userUpdated1.NotifyProps) + + status, err = th.App.GetStatus(userUpdated2.Id) + require.Nil(t, err) + assert.Equal(t, model.STATUS_ONLINE, status.Status) + +} + +func TestDisableAutoResponder(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + user := th.CreateUser() + defer th.App.PermanentDeleteUser(user) + + th.App.SetStatusOnline(user.Id, "", true) + + patch := &model.UserPatch{} + patch.NotifyProps = make(map[string]string) + patch.NotifyProps["auto_responder_active"] = "true" + patch.NotifyProps["auto_responder_message"] = "Hello, I'm unavailable today." + + th.App.PatchUser(user.Id, patch, true) + + th.App.DisableAutoResponder(user.Id, true) + + userUpdated1, err := th.App.GetUser(user.Id) + require.Nil(t, err) + assert.Equal(t, userUpdated1.NotifyProps["auto_responder_active"], "false") + + th.App.DisableAutoResponder(user.Id, true) + + userUpdated2, err := th.App.GetUser(user.Id) + require.Nil(t, err) + assert.Equal(t, userUpdated2.NotifyProps["auto_responder_active"], "false") +} + +func TestSendAutoResponseSuccess(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + user := th.CreateUser() + defer th.App.PermanentDeleteUser(user) + + patch := &model.UserPatch{} + patch.NotifyProps = make(map[string]string) + patch.NotifyProps["auto_responder_active"] = "true" + patch.NotifyProps["auto_responder_message"] = "Hello, I'm unavailable today." + + userUpdated1, err := th.App.PatchUser(user.Id, patch, true) + require.Nil(t, err) + + firstPost, err := th.App.CreatePost(&model.Post{ + ChannelId: th.BasicChannel.Id, + Message: "zz" + model.NewId() + "a", + UserId: th.BasicUser.Id}, + th.BasicChannel, + false) + + th.App.SendAutoResponse(th.BasicChannel, userUpdated1, firstPost.Id) + + if list, err := th.App.GetPosts(th.BasicChannel.Id, 0, 1); err != nil { + require.Nil(t, err) + } else { + autoResponderPostFound := false + autoResponderIsComment := false + for _, post := range list.Posts { + if post.Type == model.POST_AUTO_RESPONDER { + autoResponderIsComment = post.RootId == firstPost.Id + autoResponderPostFound = true + } + } + assert.True(t, autoResponderPostFound) + assert.True(t, autoResponderIsComment) + } +} + +func TestSendAutoResponseFailure(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + user := th.CreateUser() + defer th.App.PermanentDeleteUser(user) + + patch := &model.UserPatch{} + patch.NotifyProps = make(map[string]string) + patch.NotifyProps["auto_responder_active"] = "false" + patch.NotifyProps["auto_responder_message"] = "Hello, I'm unavailable today." + + userUpdated1, err := th.App.PatchUser(user.Id, patch, true) + require.Nil(t, err) + + firstPost, err := th.App.CreatePost(&model.Post{ + ChannelId: th.BasicChannel.Id, + Message: "zz" + model.NewId() + "a", + UserId: th.BasicUser.Id}, + th.BasicChannel, + false) + + th.App.SendAutoResponse(th.BasicChannel, userUpdated1, firstPost.Id) + + if list, err := th.App.GetPosts(th.BasicChannel.Id, 0, 1); err != nil { + require.Nil(t, err) + } else { + autoResponderPostFound := false + autoResponderIsComment := false + for _, post := range list.Posts { + if post.Type == model.POST_AUTO_RESPONDER { + autoResponderIsComment = post.RootId == firstPost.Id + autoResponderPostFound = true + } + } + assert.False(t, autoResponderPostFound) + assert.False(t, autoResponderIsComment) + } +} diff --git a/app/channel.go b/app/channel.go index 6e11d4e5d..7c14538f4 100644 --- a/app/channel.go +++ b/app/channel.go @@ -652,8 +652,10 @@ func (a *App) AddChannelMember(userId string, channel *model.Channel, userReques } var userRequestor *model.User - if userRequestor, err = a.GetUser(userRequestorId); err != nil { - return nil, err + if userRequestorId != "" { + if userRequestor, err = a.GetUser(userRequestorId); err != nil { + return nil, err + } } cm, err := a.AddUserToChannel(user, channel) @@ -661,7 +663,7 @@ func (a *App) AddChannelMember(userId string, channel *model.Channel, userReques return nil, err } - if userId == userRequestorId { + if userRequestorId == "" || userId == userRequestorId { a.postJoinChannelMessage(user, channel) } else { a.Go(func() { @@ -669,7 +671,9 @@ func (a *App) AddChannelMember(userId string, channel *model.Channel, userReques }) } - a.UpdateChannelLastViewedAt([]string{channel.Id}, userRequestor.Id) + if userRequestor != nil { + a.UpdateChannelLastViewedAt([]string{channel.Id}, userRequestor.Id) + } return cm, nil } @@ -1487,7 +1491,13 @@ func (a *App) GetDirectChannel(userId1, userId2 string) (*model.Channel, *model. } func (a *App) ToggleMuteChannel(channelId string, userId string) *model.ChannelMember { - member := (<-a.Srv.Store.Channel().GetMember(channelId, userId)).Data.(*model.ChannelMember) + result := <-a.Srv.Store.Channel().GetMember(channelId, userId) + + if result.Err != nil { + return nil + } + + member := result.Data.(*model.ChannelMember) if member.NotifyProps[model.MARK_UNREAD_NOTIFY_PROP] == model.CHANNEL_NOTIFY_MENTION { member.NotifyProps[model.MARK_UNREAD_NOTIFY_PROP] = model.CHANNEL_MARK_UNREAD_ALL diff --git a/app/channel_test.go b/app/channel_test.go index 69efaeca7..a4e0806a6 100644 --- a/app/channel_test.go +++ b/app/channel_test.go @@ -340,3 +340,44 @@ func TestRemoveUserFromChannelUpdatesChannelMemberHistoryRecord(t *testing.T) { assert.Equal(t, publicChannel.Id, histories[0].ChannelId) assert.NotNil(t, histories[0].LeaveTime) } + +func TestAddChannelMemberNoUserRequestor(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + // create a user and add it to a channel + user := th.CreateUser() + if _, err := th.App.AddTeamMember(th.BasicTeam.Id, user.Id); err != nil { + t.Fatal("Failed to add user to team. Error: " + err.Message) + } + + groupUserIds := make([]string, 0) + groupUserIds = append(groupUserIds, th.BasicUser.Id) + groupUserIds = append(groupUserIds, user.Id) + + channel := th.createChannel(th.BasicTeam, model.CHANNEL_OPEN) + userRequestorId := "" + postRootId := "" + if _, err := th.App.AddChannelMember(user.Id, channel, userRequestorId, postRootId); err != nil { + t.Fatal("Failed to add user to channel. Error: " + err.Message) + } + + // there should be a ChannelMemberHistory record for the user + histories := store.Must(th.App.Srv.Store.ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, channel.Id)).([]*model.ChannelMemberHistoryResult) + assert.Len(t, histories, 2) + channelMemberHistoryUserIds := make([]string, 0) + for _, history := range histories { + assert.Equal(t, channel.Id, history.ChannelId) + channelMemberHistoryUserIds = append(channelMemberHistoryUserIds, history.UserId) + } + assert.Equal(t, groupUserIds, channelMemberHistoryUserIds) + + postList := store.Must(th.App.Srv.Store.Post().GetPosts(channel.Id, 0, 1, false)).(*model.PostList) + if assert.Len(t, postList.Order, 1) { + post := postList.Posts[postList.Order[0]] + + assert.Equal(t, model.POST_JOIN_CHANNEL, post.Type) + assert.Equal(t, user.Id, post.UserId) + assert.Equal(t, user.Username, post.Props["username"]) + } +} diff --git a/app/command_mute.go b/app/command_mute.go index fdc698cd9..d3a2fa5f7 100644 --- a/app/command_mute.go +++ b/app/command_mute.go @@ -40,7 +40,7 @@ func (me *MuteProvider) DoCommand(a *App, args *model.CommandArgs, message strin var noChannelErr *model.AppError if channel, noChannelErr = a.GetChannel(args.ChannelId); noChannelErr != nil { - return &model.CommandResponse{Text: args.T("api.command_mute.error", map[string]interface{}{"Channel": channel.DisplayName}), ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL} + return &model.CommandResponse{Text: args.T("api.command_mute.no_channel.error"), ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL} } channelName := "" @@ -63,6 +63,9 @@ func (me *MuteProvider) DoCommand(a *App, args *model.CommandArgs, message strin } channelMember := a.ToggleMuteChannel(channel.Id, args.UserId) + if channelMember == nil { + return &model.CommandResponse{Text: args.T("api.command_mute.not_member.error", map[string]interface{}{"Channel": channelName}), ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL} + } // Invalidate cache to allow cache lookups while sending notifications a.Srv.Store.Channel().InvalidateCacheForChannelMembersNotifyProps(channel.Id) diff --git a/app/command_mute_test.go b/app/command_mute_test.go new file mode 100644 index 000000000..9f82c48cc --- /dev/null +++ b/app/command_mute_test.go @@ -0,0 +1,204 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "github.com/mattermost/mattermost-server/model" + "github.com/nicksnyder/go-i18n/i18n" + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +func TestMuteCommandNoChannel(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + if testing.Short() { + t.SkipNow() + } + + channel1 := th.BasicChannel + channel1M, channel1MError := th.App.GetChannelMember(channel1.Id, th.BasicUser.Id) + + if channel1MError != nil { + t.Fatal("User is not a member of channel 1") + } + + if channel1M.NotifyProps[model.MARK_UNREAD_NOTIFY_PROP] == model.CHANNEL_NOTIFY_MENTION { + t.Fatal("channel shouldn't be muted on initial setup") + } + + cmd := &MuteProvider{} + resp := cmd.DoCommand(th.App, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + UserId: th.BasicUser.Id, + }, "") + assert.Equal(t, "api.command_mute.no_channel.error", resp.Text) +} + +func TestMuteCommandNoArgs(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + channel1 := th.BasicChannel + channel1M, _ := th.App.GetChannelMember(channel1.Id, th.BasicUser.Id) + + assert.Equal(t, model.CHANNEL_NOTIFY_ALL, channel1M.NotifyProps[model.MARK_UNREAD_NOTIFY_PROP]) + + cmd := &MuteProvider{} + + // First mute the channel + resp := cmd.DoCommand(th.App, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + ChannelId: channel1.Id, + UserId: th.BasicUser.Id, + }, "") + assert.Equal(t, "api.command_mute.success_mute", resp.Text) + + // Now unmute the channel + time.Sleep(time.Millisecond) + resp = cmd.DoCommand(th.App, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + ChannelId: channel1.Id, + UserId: th.BasicUser.Id, + }, "") + + assert.Equal(t, "api.command_mute.success_unmute", resp.Text) +} + +func TestMuteCommandSpecificChannel(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + if testing.Short() { + t.SkipNow() + } + + channel1 := th.BasicChannel + channel2, _ := th.App.CreateChannel(&model.Channel{ + DisplayName: "AA", + Name: "aa" + model.NewId() + "a", + Type: model.CHANNEL_OPEN, + TeamId: th.BasicTeam.Id, + CreatorId: th.BasicUser.Id, + }, true) + + channel2M, _ := th.App.GetChannelMember(channel2.Id, th.BasicUser.Id) + + assert.Equal(t, model.CHANNEL_NOTIFY_ALL, channel2M.NotifyProps[model.MARK_UNREAD_NOTIFY_PROP]) + + cmd := &MuteProvider{} + + // First mute the channel + resp := cmd.DoCommand(th.App, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + ChannelId: channel1.Id, + UserId: th.BasicUser.Id, + }, channel2.Name) + assert.Equal(t, "api.command_mute.success_mute", resp.Text) + time.Sleep(time.Millisecond) + channel2M, _ = th.App.GetChannelMember(channel2.Id, th.BasicUser.Id) + assert.Equal(t, model.CHANNEL_NOTIFY_MENTION, channel2M.NotifyProps[model.MARK_UNREAD_NOTIFY_PROP]) + + // Now unmute the channel + resp = cmd.DoCommand(th.App, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + ChannelId: channel1.Id, + UserId: th.BasicUser.Id, + }, "~"+channel2.Name) + + assert.Equal(t, "api.command_mute.success_unmute", resp.Text) + time.Sleep(time.Millisecond) + channel2M, _ = th.App.GetChannelMember(channel2.Id, th.BasicUser.Id) + assert.Equal(t, model.CHANNEL_NOTIFY_ALL, channel2M.NotifyProps[model.MARK_UNREAD_NOTIFY_PROP]) +} + +func TestMuteCommandNotMember(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + if testing.Short() { + t.SkipNow() + } + + channel1 := th.BasicChannel + channel2, _ := th.App.CreateChannel(&model.Channel{ + DisplayName: "AA", + Name: "aa" + model.NewId() + "a", + Type: model.CHANNEL_OPEN, + TeamId: th.BasicTeam.Id, + CreatorId: th.BasicUser.Id, + }, false) + + cmd := &MuteProvider{} + + // First mute the channel + resp := cmd.DoCommand(th.App, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + ChannelId: channel1.Id, + UserId: th.BasicUser.Id, + }, channel2.Name) + assert.Equal(t, "api.command_mute.not_member.error", resp.Text) +} + +func TestMuteCommandNotChannel(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + if testing.Short() { + t.SkipNow() + } + + channel1 := th.BasicChannel + + cmd := &MuteProvider{} + + // First mute the channel + resp := cmd.DoCommand(th.App, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + ChannelId: channel1.Id, + UserId: th.BasicUser.Id, + }, "~noexists") + assert.Equal(t, "api.command_mute.error", resp.Text) +} + +func TestMuteCommandDMChannel(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + if testing.Short() { + t.SkipNow() + } + + channel2, _ := th.App.CreateDirectChannel(th.BasicUser.Id, th.BasicUser2.Id) + channel2M, _ := th.App.GetChannelMember(channel2.Id, th.BasicUser.Id) + + assert.Equal(t, model.CHANNEL_NOTIFY_ALL, channel2M.NotifyProps[model.MARK_UNREAD_NOTIFY_PROP]) + + cmd := &MuteProvider{} + + // First mute the channel + resp := cmd.DoCommand(th.App, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + ChannelId: channel2.Id, + UserId: th.BasicUser.Id, + }, "") + assert.Equal(t, "api.command_mute.success_mute_direct_msg", resp.Text) + time.Sleep(time.Millisecond) + channel2M, _ = th.App.GetChannelMember(channel2.Id, th.BasicUser.Id) + assert.Equal(t, model.CHANNEL_NOTIFY_MENTION, channel2M.NotifyProps[model.MARK_UNREAD_NOTIFY_PROP]) + + // Now unmute the channel + resp = cmd.DoCommand(th.App, &model.CommandArgs{ + T: i18n.IdentityTfunc(), + ChannelId: channel2.Id, + UserId: th.BasicUser.Id, + }, "") + + assert.Equal(t, "api.command_mute.success_unmute_direct_msg", resp.Text) + time.Sleep(time.Millisecond) + channel2M, _ = th.App.GetChannelMember(channel2.Id, th.BasicUser.Id) + assert.Equal(t, model.CHANNEL_NOTIFY_ALL, channel2M.NotifyProps[model.MARK_UNREAD_NOTIFY_PROP]) +} diff --git a/app/config.go b/app/config.go index 761fe3ec9..75d38e24a 100644 --- a/app/config.go +++ b/app/config.go @@ -30,6 +30,13 @@ func (a *App) Config() *model.Config { return &model.Config{} } +func (a *App) EnvironmentConfig() map[string]interface{} { + if a.envConfig != nil { + return a.envConfig + } + return map[string]interface{}{} +} + func (a *App) UpdateConfig(f func(*model.Config)) { old := a.Config() updated := old.Clone() @@ -46,7 +53,7 @@ func (a *App) PersistConfig() { func (a *App) LoadConfig(configFile string) *model.AppError { old := a.Config() - cfg, configPath, err := utils.LoadConfig(configFile) + cfg, configPath, envConfig, err := utils.LoadConfig(configFile) if err != nil { return err } @@ -57,6 +64,7 @@ func (a *App) LoadConfig(configFile string) *model.AppError { l4g.Info("Using config file at %s", configPath) a.config.Store(cfg) + a.envConfig = envConfig a.siteURL = strings.TrimRight(*cfg.ServiceSettings.SiteURL, "/") diff --git a/app/notification.go b/app/notification.go index 5b7ed5ad2..2d56d294a 100644 --- a/app/notification.go +++ b/app/notification.go @@ -66,13 +66,25 @@ func (a *App) SendNotifications(post *model.Post, team *model.Team, channel *mod } } - if _, ok := profileMap[otherUserId]; ok { + otherUser, ok := profileMap[otherUserId] + if ok { mentionedUserIds[otherUserId] = true } if post.Props["from_webhook"] == "true" { mentionedUserIds[post.UserId] = true } + + if post.Type != model.POST_AUTO_RESPONDER { + a.Go(func() { + rootId := post.Id + if post.RootId != "" && post.RootId != post.Id { + rootId = post.RootId + } + a.SendAutoResponse(channel, otherUser, rootId) + }) + } + } else { keywords := a.GetMentionKeywordsInChannel(profileMap, post.Type != model.POST_HEADER_CHANGE && post.Type != model.POST_PURPOSE_CHANGE) @@ -842,15 +854,15 @@ func GetExplicitMentions(message string, keywords map[string][]string) *Explicit checkForMention := func(word string) bool { isMention := false - if word == "@here" { + if strings.ToLower(word) == "@here" { ret.HereMentioned = true } - if word == "@channel" { + if strings.ToLower(word) == "@channel" { ret.ChannelMentioned = true } - if word == "@all" { + if strings.ToLower(word) == "@all" { ret.AllMentioned = true } @@ -1021,8 +1033,8 @@ func DoesNotifyPropsAllowPushNotification(user *model.User, channelNotifyProps m } func DoesStatusAllowPushNotification(userNotifyProps model.StringMap, status *model.Status, channelId string) bool { - // If User status is DND return false right away - if status.Status == model.STATUS_DND { + // If User status is DND or OOO return false right away + if status.Status == model.STATUS_DND || status.Status == model.STATUS_OUT_OF_OFFICE { return false } diff --git a/app/notification_test.go b/app/notification_test.go index 1abdd3d61..8df19e2bf 100644 --- a/app/notification_test.go +++ b/app/notification_test.go @@ -186,6 +186,17 @@ func TestGetExplicitMentions(t *testing.T) { ChannelMentioned: true, }, }, + "CapitalizedChannel": { + Message: "this is an message for @cHaNNeL", + Keywords: map[string][]string{"@channel": {id1, id2}}, + Expected: &ExplicitMentions{ + MentionedUserIds: map[string]bool{ + id1: true, + id2: true, + }, + ChannelMentioned: true, + }, + }, "All": { Message: "this is an message for @all", Keywords: map[string][]string{"@all": {id1, id2}}, @@ -197,6 +208,17 @@ func TestGetExplicitMentions(t *testing.T) { AllMentioned: true, }, }, + "CapitalizedAll": { + Message: "this is an message for @ALL", + Keywords: map[string][]string{"@all": {id1, id2}}, + Expected: &ExplicitMentions{ + MentionedUserIds: map[string]bool{ + id1: true, + id2: true, + }, + AllMentioned: true, + }, + }, "UserWithPeriod": { Message: "user.period doesn't complicate things at all by including periods in their username", Keywords: map[string][]string{"user.period": {id1}, "user": {id2}}, @@ -439,6 +461,8 @@ func TestGetExplicitMentionsAtHere(t *testing.T) { "?@here?": true, "`@here`": false, // This case shouldn't mention since it's a code block "~@here~": true, + "@HERE": true, + "@hERe": true, } for message, shouldMention := range cases { diff --git a/app/plugin_api.go b/app/plugin_api.go index 21b828368..b09a0c419 100644 --- a/app/plugin_api.go +++ b/app/plugin_api.go @@ -124,10 +124,35 @@ func (api *PluginAPI) UpdateChannel(channel *model.Channel) (*model.Channel, *mo return api.app.UpdateChannel(channel) } +func (api *PluginAPI) AddChannelMember(channelId, userId string) (*model.ChannelMember, *model.AppError) { + // For now, don't allow overriding these via the plugin API. + userRequestorId := "" + postRootId := "" + + channel, err := api.GetChannel(channelId) + if err != nil { + return nil, err + } + + return api.app.AddChannelMember(userId, channel, userRequestorId, postRootId) +} + func (api *PluginAPI) GetChannelMember(channelId, userId string) (*model.ChannelMember, *model.AppError) { return api.app.GetChannelMember(channelId, userId) } +func (api *PluginAPI) UpdateChannelMemberRoles(channelId, userId, newRoles string) (*model.ChannelMember, *model.AppError) { + return api.app.UpdateChannelMemberRoles(channelId, userId, newRoles) +} + +func (api *PluginAPI) UpdateChannelMemberNotifications(channelId, userId string, notifications map[string]string) (*model.ChannelMember, *model.AppError) { + return api.app.UpdateChannelMemberNotifyProps(notifications, channelId, userId) +} + +func (api *PluginAPI) DeleteChannelMember(channelId, userId string) *model.AppError { + return api.app.LeaveChannel(channelId, userId) +} + func (api *PluginAPI) CreatePost(post *model.Post) (*model.Post, *model.AppError) { return api.app.CreatePostMissingChannel(post, true) } diff --git a/app/status.go b/app/status.go index c8bff0d1a..64d5379ef 100644 --- a/app/status.go +++ b/app/status.go @@ -303,6 +303,32 @@ func (a *App) SaveAndBroadcastStatus(status *model.Status) *model.AppError { return nil } +func (a *App) SetStatusOutOfOffice(userId string) { + if !*a.Config().ServiceSettings.EnableUserStatuses { + return + } + + status, err := a.GetStatus(userId) + + if err != nil { + status = &model.Status{UserId: userId, Status: model.STATUS_OUT_OF_OFFICE, Manual: false, LastActivityAt: 0, ActiveChannel: ""} + } + + status.Status = model.STATUS_OUT_OF_OFFICE + status.Manual = true + + a.AddStatusCache(status) + + if result := <-a.Srv.Store.Status().SaveOrUpdate(status); result.Err != nil { + l4g.Error(utils.T("api.status.save_status.error"), userId, result.Err) + } + + event := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_STATUS_CHANGE, "", "", status.UserId, nil) + event.Add("status", model.STATUS_OUT_OF_OFFICE) + event.Add("user_id", status.UserId) + a.Publish(event) +} + func GetStatusFromCache(userId string) *model.Status { if result, ok := statusCache.Get(userId); ok { status := result.(*model.Status) diff --git a/cmd/commands/channel.go b/cmd/commands/channel.go index 597a22450..d7b48f04c 100644 --- a/cmd/commands/channel.go +++ b/cmd/commands/channel.go @@ -31,7 +31,7 @@ var RemoveChannelUsersCmd = &cobra.Command{ Use: "remove [channel] [users]", Short: "Remove users from channel", Long: "Remove some users from channel", - Example: " channel remove mychannel user@example.com username", + Example: " channel remove myteam:mychannel user@example.com username", RunE: removeChannelUsersCmdF, } @@ -39,7 +39,7 @@ var AddChannelUsersCmd = &cobra.Command{ Use: "add [channel] [users]", Short: "Add users to channel", Long: "Add some users to channel", - Example: " channel add mychannel user@example.com username", + Example: " channel add myteam:mychannel user@example.com username", RunE: addChannelUsersCmdF, } diff --git a/cmd/commands/config_flag_test.go b/cmd/commands/config_flag_test.go index 7ea0d5153..f31c989d8 100644 --- a/cmd/commands/config_flag_test.go +++ b/cmd/commands/config_flag_test.go @@ -22,7 +22,7 @@ func TestConfigFlag(t *testing.T) { defer os.RemoveAll(dir) utils.TranslationsPreInit() - config, _, err := utils.LoadConfig("config.json") + config, _, _, err := utils.LoadConfig("config.json") require.Nil(t, err) configPath := filepath.Join(dir, "foo.json") require.NoError(t, ioutil.WriteFile(configPath, []byte(config.ToJson()), 0600)) diff --git a/cmd/commands/message_export_test.go b/cmd/commands/message_export_test.go index 5170b77af..bd0e049d6 100644 --- a/cmd/commands/message_export_test.go +++ b/cmd/commands/message_export_test.go @@ -57,7 +57,7 @@ func writeTempConfig(t *testing.T, isMessageExportEnabled bool) string { require.NoError(t, err) utils.TranslationsPreInit() - config, _, appErr := utils.LoadConfig("config.json") + config, _, _, appErr := utils.LoadConfig("config.json") require.Nil(t, appErr) config.MessageExportSettings.EnableExport = model.NewBool(isMessageExportEnabled) configPath := filepath.Join(dir, "foo.json") diff --git a/cmd/commands/team.go b/cmd/commands/team.go index 9c07b7456..05de714d6 100644 --- a/cmd/commands/team.go +++ b/cmd/commands/team.go @@ -52,6 +52,14 @@ Permanently deletes a team along with all related information including posts fr RunE: deleteTeamsCmdF, } +var ListTeamsCmd = &cobra.Command{ + Use: "list", + Short: "List all teams.", + Long: `List all teams on the server.`, + Example: " team list", + RunE: listTeamsCmdF, +} + func init() { TeamCreateCmd.Flags().String("name", "", "Team Name") TeamCreateCmd.Flags().String("display_name", "", "Team Display Name") @@ -65,6 +73,7 @@ func init() { RemoveUsersCmd, AddUsersCmd, DeleteTeamsCmd, + ListTeamsCmd, ) cmd.RootCmd.AddCommand(TeamCmd) } @@ -216,3 +225,21 @@ func deleteTeamsCmdF(command *cobra.Command, args []string) error { func deleteTeam(a *app.App, team *model.Team) *model.AppError { return a.PermanentDeleteTeam(team) } + +func listTeamsCmdF(command *cobra.Command, args []string) error { + a, err := cmd.InitDBCommandContextCobra(command) + if err != nil { + return err + } + + teams, err2 := a.GetAllTeams() + if err2 != nil { + return err2 + } + + for _, team := range teams { + cmd.CommandPrettyPrintln(team.Name) + } + + return nil +} diff --git a/cmd/commands/team_test.go b/cmd/commands/team_test.go index 1a91df4bc..ac006dbe1 100644 --- a/cmd/commands/team_test.go +++ b/cmd/commands/team_test.go @@ -4,6 +4,7 @@ package commands import ( + "strings" "testing" "github.com/mattermost/mattermost-server/api" @@ -78,3 +79,20 @@ func TestLeaveTeam(t *testing.T) { } } } + +func TestListTeams(t *testing.T) { + th := api.Setup().InitBasic() + defer th.TearDown() + + id := model.NewId() + name := "name" + id + displayName := "Name " + id + + cmd.CheckCommand(t, "team", "create", "--name", name, "--display_name", displayName) + + output := cmd.CheckCommand(t, "team", "list", th.BasicTeam.Name, th.BasicUser.Email) + + if !strings.Contains(string(output), name) { + t.Fatal("should have the created team") + } +} diff --git a/config/default.json b/config/default.json index 56783753b..584380453 100644 --- a/config/default.json +++ b/config/default.json @@ -89,6 +89,7 @@ "MaxNotificationsPerChannel": 1000, "EnableConfirmNotificationsToChannel": true, "TeammateNameDisplay": "username", + "ExperimentalEnableAutomaticReplies": false, "ExperimentalTownSquareIsReadOnly": false, "ExperimentalPrimaryTeam": "" }, diff --git a/glide.lock b/glide.lock index 4915b477b..9fc53b688 100644 --- a/glide.lock +++ b/glide.lock @@ -208,7 +208,7 @@ imports: - name: github.com/spf13/cast version: 8965335b8c7107321228e3e3702cab9832751bac - name: github.com/spf13/cobra - version: be77323fc05148ef091e83b3866c0d47c8e74a8b + version: 4f5003aa93559718c866d86fbc795439079484f5 - name: github.com/spf13/jwalterweatherman version: 7c0cea34c8ece3fbeb2b27ab9b59511d360fb394 - name: github.com/spf13/pflag diff --git a/glide.yaml b/glide.yaml index eb198b17e..d166f8573 100644 --- a/glide.yaml +++ b/glide.yaml @@ -50,6 +50,8 @@ import: version: 2.1.1 - package: github.com/spf13/cobra - package: github.com/spf13/viper + vcs: git + repo: https://github.com/hmhealey/viper.git - package: github.com/stretchr/testify version: v1.2.1 subpackages: diff --git a/i18n/en.json b/i18n/en.json index bf93e5b1c..02ac46e70 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -907,6 +907,14 @@ "translation": "Could not find the channel {{.Channel}}. Please use the [channel handle](https://about.mattermost.com/default-channel-handle-documentation) to identify channels." }, { + "id": "api.command_mute.no_channel.error", + "translation": "Could not find the specified channel. Please use the [channel handle](https://about.mattermost.com/default-channel-handle-documentation) to identify channels." + }, + { + "id": "api.command_mute.not_member.error", + "translation": "Could not mute channel {{.Channel}} as you are not a member." + }, + { "id": "api.command_mute.hint", "translation": "~[channel]" }, @@ -3087,6 +3095,10 @@ "translation": "Could not parse multipart form" }, { + "id": "api.user.get_profile_image.not_found.app_error", + "translation": "Unable to get profile image, user not found." + }, + { "id": "api.user.upload_profile_user.storage.app_error", "translation": "Unable to upload file. Image storage is not configured." }, @@ -7328,13 +7340,17 @@ }, { "id": "utils.mail.send_mail.from_address.app_error", - "translation": "Notification From Address setting is missing or invalid." + "translation": "Error setting \"From Address\"" }, { "id": "utils.mail.send_mail.msg.app_error", "translation": "Failed to write email message" }, { + "id": "app.admin.test_email.failure", + "translation": "Connection unsuccessful: {{.Error}}" + }, + { "id": "utils.mail.send_mail.msg_data.app_error", "translation": "Failed to add email message data" }, @@ -7344,7 +7360,7 @@ }, { "id": "utils.mail.send_mail.to_address.app_error", - "translation": "Notification To Address setting is missing or invalid." + "translation": "Error setting \"To Address\"" }, { "id": "utils.mail.test.configured.error", diff --git a/model/client4.go b/model/client4.go index 3346cc6eb..82d380440 100644 --- a/model/client4.go +++ b/model/client4.go @@ -2167,6 +2167,18 @@ func (c *Client4) GetOldClientConfig(etag string) (map[string]string, *Response) } } +// GetEnvironmentConfig will retrieve a map mirroring the server configuration where fields +// are set to true if the corresponding config setting is set through an environment variable. +// Settings that haven't been set through environment variables will be missing from the map. +func (c *Client4) GetEnvironmentConfig() (map[string]interface{}, *Response) { + if r, err := c.DoApiGet(c.GetConfigRoute()+"/environment", ""); err != nil { + return nil, BuildErrorResponse(r, err) + } else { + defer closeBody(r) + return StringInterfaceFromJson(r.Body), BuildResponse(r) + } +} + // GetOldClientLicense will retrieve the parts of the server license needed by the // client, formatted in the old format. func (c *Client4) GetOldClientLicense(etag string) (map[string]string, *Response) { diff --git a/model/config.go b/model/config.go index 6c6cf90e9..93533b8aa 100644 --- a/model/config.go +++ b/model/config.go @@ -991,6 +991,7 @@ type TeamSettings struct { MaxNotificationsPerChannel *int64 EnableConfirmNotificationsToChannel *bool TeammateNameDisplay *string + ExperimentalEnableAutomaticReplies *bool ExperimentalTownSquareIsReadOnly *bool ExperimentalPrimaryTeam *string } @@ -1085,6 +1086,10 @@ func (s *TeamSettings) SetDefaults() { s.EnableConfirmNotificationsToChannel = NewBool(true) } + if s.ExperimentalEnableAutomaticReplies == nil { + s.ExperimentalEnableAutomaticReplies = NewBool(false) + } + if s.ExperimentalTownSquareIsReadOnly == nil { s.ExperimentalTownSquareIsReadOnly = NewBool(false) } @@ -1710,8 +1715,8 @@ func (s *MessageExportSettings) SetDefaults() { if s.GlobalRelaySettings == nil { s.GlobalRelaySettings = &GlobalRelayMessageExportSettings{} - s.GlobalRelaySettings.SetDefaults() } + s.GlobalRelaySettings.SetDefaults() } type DisplaySettings struct { diff --git a/model/config_test.go b/model/config_test.go index 1f917af27..b7533145b 100644 --- a/model/config_test.go +++ b/model/config_test.go @@ -4,11 +4,57 @@ package model import ( + "fmt" + "reflect" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestConfigDefaults(t *testing.T) { + t.Parallel() + + t.Run("somewhere nil when uninitialized", func(t *testing.T) { + c := Config{} + require.False(t, checkNowhereNil(t, "config", c)) + }) + + t.Run("nowhere nil when initialized", func(t *testing.T) { + c := Config{} + c.SetDefaults() + require.True(t, checkNowhereNil(t, "config", c)) + }) + + t.Run("nowhere nil when partially initialized", func(t *testing.T) { + var recursivelyUninitialize func(*Config, string, reflect.Value) + recursivelyUninitialize = func(config *Config, name string, v reflect.Value) { + if v.Type().Kind() == reflect.Ptr { + // Set every pointer we find in the tree to nil + v.Set(reflect.Zero(v.Type())) + require.True(t, v.IsNil()) + + // SetDefaults on the root config should make it non-nil, otherwise + // it means that SetDefaults isn't being called recursively in + // all cases. + config.SetDefaults() + if assert.False(t, v.IsNil(), "%s should be non-nil after SetDefaults()", name) { + recursivelyUninitialize(config, fmt.Sprintf("(*%s)", name), v.Elem()) + } + + } else if v.Type().Kind() == reflect.Struct { + for i := 0; i < v.NumField(); i++ { + recursivelyUninitialize(config, fmt.Sprintf("%s.%s", name, v.Type().Field(i).Name), v.Field(i)) + } + } + } + + c := Config{} + c.SetDefaults() + recursivelyUninitialize(&c, "config", reflect.ValueOf(&c).Elem()) + }) +} + func TestConfigDefaultFileSettingsDirectory(t *testing.T) { c1 := Config{} c1.SetDefaults() diff --git a/model/post.go b/model/post.go index 09303c0cd..e74496979 100644 --- a/model/post.go +++ b/model/post.go @@ -25,6 +25,7 @@ const ( POST_LEAVE_CHANNEL = "system_leave_channel" POST_JOIN_TEAM = "system_join_team" POST_LEAVE_TEAM = "system_leave_team" + POST_AUTO_RESPONDER = "system_auto_responder" 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" POST_REMOVE_FROM_CHANNEL = "system_remove_from_channel" @@ -194,6 +195,7 @@ func (o *Post) IsValid(maxPostSize int) *AppError { case POST_DEFAULT, POST_JOIN_LEAVE, + POST_AUTO_RESPONDER, POST_ADD_REMOVE, POST_JOIN_CHANNEL, POST_LEAVE_CHANNEL, diff --git a/model/status.go b/model/status.go index cd9e32ed3..cf5899446 100644 --- a/model/status.go +++ b/model/status.go @@ -9,6 +9,7 @@ import ( ) const ( + STATUS_OUT_OF_OFFICE = "ooo" STATUS_OFFLINE = "offline" STATUS_AWAY = "away" STATUS_DND = "dnd" diff --git a/model/utils.go b/model/utils.go index 72369852b..2d61b49f6 100644 --- a/model/utils.go +++ b/model/utils.go @@ -15,9 +15,11 @@ import ( "net/http" "net/mail" "net/url" + "reflect" "regexp" "strconv" "strings" + "testing" "time" "unicode" @@ -469,3 +471,60 @@ func IsValidId(value string) bool { return true } + +// checkNowhereNil checks that the given interface value is not nil, and if a struct, that all of +// its public fields are also nowhere nil +func checkNowhereNil(t *testing.T, name string, value interface{}) bool { + if value == nil { + return false + } + + v := reflect.ValueOf(value) + switch v.Type().Kind() { + case reflect.Ptr: + if v.IsNil() { + t.Logf("%s was nil", name) + return false + } + + return checkNowhereNil(t, fmt.Sprintf("(*%s)", name), v.Elem().Interface()) + + case reflect.Map: + if v.IsNil() { + t.Logf("%s was nil", name) + return false + } + + // Don't check map values + return true + + case reflect.Struct: + nowhereNil := true + for i := 0; i < v.NumField(); i++ { + f := v.Field(i) + // Ignore unexported fields + if v.Type().Field(i).PkgPath != "" { + continue + } + + nowhereNil = nowhereNil && checkNowhereNil(t, fmt.Sprintf("%s.%s", name, v.Type().Field(i).Name), f.Interface()) + } + + return nowhereNil + + case reflect.Array: + fallthrough + case reflect.Chan: + fallthrough + case reflect.Func: + fallthrough + case reflect.Interface: + fallthrough + case reflect.UnsafePointer: + t.Logf("unhandled field %s, type: %s", name, v.Type().Kind()) + return false + + default: + return true + } +} diff --git a/model/utils_test.go b/model/utils_test.go index 411d7bf50..92354c0a1 100644 --- a/model/utils_test.go +++ b/model/utils_test.go @@ -7,6 +7,8 @@ import ( "net/http" "strings" "testing" + + "github.com/stretchr/testify/require" ) func TestNewId(t *testing.T) { @@ -367,3 +369,184 @@ func TestIsValidId(t *testing.T) { } } } + +func TestNowhereNil(t *testing.T) { + t.Parallel() + + var nilStringPtr *string + var nonNilStringPtr *string = new(string) + var nilSlice []string + var nilStruct *struct{} + var nilMap map[bool]bool + + var nowhereNilStruct = struct { + X *string + Y *string + }{ + nonNilStringPtr, + nonNilStringPtr, + } + var somewhereNilStruct = struct { + X *string + Y *string + }{ + nonNilStringPtr, + nilStringPtr, + } + + var privateSomewhereNilStruct = struct { + X *string + y *string + }{ + nonNilStringPtr, + nilStringPtr, + } + + testCases := []struct { + Description string + Value interface{} + Expected bool + }{ + { + "nil", + nil, + false, + }, + { + "empty string", + "", + true, + }, + { + "non-empty string", + "not empty!", + true, + }, + { + "nil string pointer", + nilStringPtr, + false, + }, + { + "non-nil string pointer", + nonNilStringPtr, + true, + }, + { + "0", + 0, + true, + }, + { + "1", + 1, + true, + }, + { + "0 (int64)", + int64(0), + true, + }, + { + "1 (int64)", + int64(1), + true, + }, + { + "true", + true, + true, + }, + { + "false", + false, + true, + }, + { + "nil slice", + nilSlice, + // A nil slice is observably the same as an empty slice, so allow it. + true, + }, + { + "empty slice", + []string{}, + true, + }, + { + "slice containing nils", + []*string{nil, nil}, + true, + }, + { + "nil map", + nilMap, + false, + }, + { + "non-nil map", + make(map[bool]bool), + true, + }, + { + "non-nil map containing nil", + map[bool]*string{true: nilStringPtr, false: nonNilStringPtr}, + // Map values are not checked + true, + }, + { + "nil struct", + nilStruct, + false, + }, + { + "empty struct", + struct{}{}, + true, + }, + { + "struct containing no nil", + nowhereNilStruct, + true, + }, + { + "struct containing nil", + somewhereNilStruct, + false, + }, + { + "struct pointer containing no nil", + &nowhereNilStruct, + true, + }, + { + "struct pointer containing nil", + &somewhereNilStruct, + false, + }, + { + "struct containing private nil", + privateSomewhereNilStruct, + true, + }, + { + "struct pointer containing private nil", + &privateSomewhereNilStruct, + true, + }, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.Description, func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("panic: %v", r) + } + }() + + t.Parallel() + require.Equal(t, testCase.Expected, checkNowhereNil(t, "value", testCase.Value)) + }) + } +} diff --git a/model/websocket_client.go b/model/websocket_client.go index cdec75aba..4ff4f617b 100644 --- a/model/websocket_client.go +++ b/model/websocket_client.go @@ -29,7 +29,13 @@ type WebSocketClient struct { // NewWebSocketClient constructs a new WebSocket client with convenience // methods for talking to the server. func NewWebSocketClient(url, authToken string) (*WebSocketClient, *AppError) { - conn, _, err := websocket.DefaultDialer.Dial(url+API_URL_SUFFIX_V3+"/users/websocket", nil) + return NewWebSocketClientWithDialer(websocket.DefaultDialer, url, authToken) +} + +// NewWebSocketClientWithDialer constructs a new WebSocket client with convenience +// methods for talking to the server using a custom dialer. +func NewWebSocketClientWithDialer(dialer *websocket.Dialer, url, authToken string) (*WebSocketClient, *AppError) { + conn, _, err := dialer.Dial(url+API_URL_SUFFIX_V3+"/users/websocket", nil) if err != nil { return nil, NewAppError("NewWebSocketClient", "model.websocket_client.connect_fail.app_error", nil, err.Error(), http.StatusInternalServerError) } @@ -54,7 +60,13 @@ func NewWebSocketClient(url, authToken string) (*WebSocketClient, *AppError) { // NewWebSocketClient4 constructs a new WebSocket client with convenience // methods for talking to the server. Uses the v4 endpoint. func NewWebSocketClient4(url, authToken string) (*WebSocketClient, *AppError) { - conn, _, err := websocket.DefaultDialer.Dial(url+API_URL_SUFFIX+"/websocket", nil) + return NewWebSocketClient4WithDialer(websocket.DefaultDialer, url, authToken) +} + +// NewWebSocketClient4WithDialer constructs a new WebSocket client with convenience +// methods for talking to the server using a custom dialer. Uses the v4 endpoint. +func NewWebSocketClient4WithDialer(dialer *websocket.Dialer, url, authToken string) (*WebSocketClient, *AppError) { + conn, _, err := dialer.Dial(url+API_URL_SUFFIX+"/websocket", nil) if err != nil { return nil, NewAppError("NewWebSocketClient4", "model.websocket_client.connect_fail.app_error", nil, err.Error(), http.StatusInternalServerError) } @@ -77,8 +89,12 @@ func NewWebSocketClient4(url, authToken string) (*WebSocketClient, *AppError) { } func (wsc *WebSocketClient) Connect() *AppError { + return wsc.ConnectWithDialer(websocket.DefaultDialer) +} + +func (wsc *WebSocketClient) ConnectWithDialer(dialer *websocket.Dialer) *AppError { var err error - wsc.Conn, _, err = websocket.DefaultDialer.Dial(wsc.ConnectUrl, nil) + wsc.Conn, _, err = dialer.Dial(wsc.ConnectUrl, nil) if err != nil { return NewAppError("Connect", "model.websocket_client.connect_fail.app_error", nil, err.Error(), http.StatusInternalServerError) } diff --git a/plugin/api.go b/plugin/api.go index 437188f6e..d62c2f069 100644 --- a/plugin/api.go +++ b/plugin/api.go @@ -77,9 +77,21 @@ type API interface { // UpdateChannel updates a channel. UpdateChannel(channel *model.Channel) (*model.Channel, *model.AppError) + // AddChannelMember creates a channel membership for a user. + AddChannelMember(channelId, userId string) (*model.ChannelMember, *model.AppError) + // GetChannelMember gets a channel membership for a user. GetChannelMember(channelId, userId string) (*model.ChannelMember, *model.AppError) + // UpdateChannelMemberRoles updates a user's roles for a channel. + UpdateChannelMemberRoles(channelId, userId, newRoles string) (*model.ChannelMember, *model.AppError) + + // UpdateChannelMemberNotifications updates a user's notification properties for a channel. + UpdateChannelMemberNotifications(channelId, userId string, notifications map[string]string) (*model.ChannelMember, *model.AppError) + + // DeleteChannelMember deletes a channel membership for a user. + DeleteChannelMember(channelId, userId string) *model.AppError + // CreatePost creates a post. CreatePost(post *model.Post) (*model.Post, *model.AppError) diff --git a/plugin/plugintest/api.go b/plugin/plugintest/api.go index 75174a9a6..8f9f4a604 100644 --- a/plugin/plugintest/api.go +++ b/plugin/plugintest/api.go @@ -223,6 +223,16 @@ func (m *API) UpdateChannel(channel *model.Channel) (*model.Channel, *model.AppE return channelOut, err } +func (m *API) AddChannelMember(channelId, userId string) (*model.ChannelMember, *model.AppError) { + ret := m.Called(channelId, userId) + if f, ok := ret.Get(0).(func(_, _ string) (*model.ChannelMember, *model.AppError)); ok { + return f(channelId, userId) + } + member, _ := ret.Get(0).(*model.ChannelMember) + err, _ := ret.Get(1).(*model.AppError) + return member, err +} + func (m *API) GetChannelMember(channelId, userId string) (*model.ChannelMember, *model.AppError) { ret := m.Called(channelId, userId) if f, ok := ret.Get(0).(func(_, _ string) (*model.ChannelMember, *model.AppError)); ok { @@ -233,6 +243,35 @@ func (m *API) GetChannelMember(channelId, userId string) (*model.ChannelMember, return member, err } +func (m *API) UpdateChannelMemberRoles(channelId, userId, newRoles string) (*model.ChannelMember, *model.AppError) { + ret := m.Called(channelId, userId, newRoles) + if f, ok := ret.Get(0).(func(_, _, _ string) (*model.ChannelMember, *model.AppError)); ok { + return f(channelId, userId, newRoles) + } + member, _ := ret.Get(0).(*model.ChannelMember) + err, _ := ret.Get(1).(*model.AppError) + return member, err +} + +func (m *API) UpdateChannelMemberNotifications(channelId, userId string, notifications map[string]string) (*model.ChannelMember, *model.AppError) { + ret := m.Called(channelId, userId, notifications) + if f, ok := ret.Get(0).(func(_, _ string, _ map[string]string) (*model.ChannelMember, *model.AppError)); ok { + return f(channelId, userId, notifications) + } + member, _ := ret.Get(0).(*model.ChannelMember) + err, _ := ret.Get(1).(*model.AppError) + return member, err +} + +func (m *API) DeleteChannelMember(channelId, userId string) *model.AppError { + ret := m.Called(channelId, userId) + if f, ok := ret.Get(0).(func(_, _ string) *model.AppError); ok { + return f(channelId, userId) + } + err, _ := ret.Get(0).(*model.AppError) + return err +} + func (m *API) CreatePost(post *model.Post) (*model.Post, *model.AppError) { ret := m.Called(post) if f, ok := ret.Get(0).(func(*model.Post) (*model.Post, *model.AppError)); ok { diff --git a/plugin/rpcplugin/api.go b/plugin/rpcplugin/api.go index d87f65b55..c81bbb7c5 100644 --- a/plugin/rpcplugin/api.go +++ b/plugin/rpcplugin/api.go @@ -163,11 +163,33 @@ type APIGetGroupChannelArgs struct { UserIds []string } +type APIAddChannelMemberArgs struct { + ChannelId string + UserId string +} + type APIGetChannelMemberArgs struct { ChannelId string UserId string } +type APIUpdateChannelMemberRolesArgs struct { + ChannelId string + UserId string + NewRoles string +} + +type APIUpdateChannelMemberNotificationsArgs struct { + ChannelId string + UserId string + Notifications map[string]string +} + +type APIDeleteChannelMemberArgs struct { + ChannelId string + UserId string +} + type APIChannelReply struct { Channel *model.Channel Error *model.AppError @@ -239,6 +261,15 @@ func (api *LocalAPI) UpdateChannel(args *model.Channel, reply *APIChannelReply) return nil } +func (api *LocalAPI) AddChannelMember(args *APIAddChannelMemberArgs, reply *APIChannelMemberReply) error { + member, err := api.api.AddChannelMember(args.ChannelId, args.UserId) + *reply = APIChannelMemberReply{ + ChannelMember: member, + Error: err, + } + return nil +} + func (api *LocalAPI) GetChannelMember(args *APIGetChannelMemberArgs, reply *APIChannelMemberReply) error { member, err := api.api.GetChannelMember(args.ChannelId, args.UserId) *reply = APIChannelMemberReply{ @@ -248,6 +279,32 @@ func (api *LocalAPI) GetChannelMember(args *APIGetChannelMemberArgs, reply *APIC return nil } +func (api *LocalAPI) UpdateChannelMemberRoles(args *APIUpdateChannelMemberRolesArgs, reply *APIChannelMemberReply) error { + member, err := api.api.UpdateChannelMemberRoles(args.ChannelId, args.UserId, args.NewRoles) + *reply = APIChannelMemberReply{ + ChannelMember: member, + Error: err, + } + return nil +} + +func (api *LocalAPI) UpdateChannelMemberNotifications(args *APIUpdateChannelMemberNotificationsArgs, reply *APIChannelMemberReply) error { + member, err := api.api.UpdateChannelMemberNotifications(args.ChannelId, args.UserId, args.Notifications) + *reply = APIChannelMemberReply{ + ChannelMember: member, + Error: err, + } + return nil +} + +func (api *LocalAPI) DeleteChannelMember(args *APIDeleteChannelMemberArgs, reply *APIErrorReply) error { + err := api.api.DeleteChannelMember(args.ChannelId, args.UserId) + *reply = APIErrorReply{ + Error: err, + } + return nil +} + type APIPostReply struct { Post *model.Post Error *model.AppError @@ -520,6 +577,17 @@ func (api *RemoteAPI) UpdateChannel(channel *model.Channel) (*model.Channel, *mo return reply.Channel, reply.Error } +func (api *RemoteAPI) AddChannelMember(channelId, userId string) (*model.ChannelMember, *model.AppError) { + var reply APIChannelMemberReply + if err := api.client.Call("LocalAPI.AddChannelMember", &APIAddChannelMemberArgs{ + ChannelId: channelId, + UserId: userId, + }, &reply); err != nil { + return nil, model.NewAppError("RemoteAPI.AddChannelMember", "plugin.rpcplugin.invocation.error", nil, "err="+err.Error(), http.StatusInternalServerError) + } + return reply.ChannelMember, reply.Error +} + func (api *RemoteAPI) GetChannelMember(channelId, userId string) (*model.ChannelMember, *model.AppError) { var reply APIChannelMemberReply if err := api.client.Call("LocalAPI.GetChannelMember", &APIGetChannelMemberArgs{ @@ -531,6 +599,41 @@ func (api *RemoteAPI) GetChannelMember(channelId, userId string) (*model.Channel return reply.ChannelMember, reply.Error } +func (api *RemoteAPI) UpdateChannelMemberRoles(channelId, userId, newRoles string) (*model.ChannelMember, *model.AppError) { + var reply APIChannelMemberReply + if err := api.client.Call("LocalAPI.UpdateChannelMemberRoles", &APIUpdateChannelMemberRolesArgs{ + ChannelId: channelId, + UserId: userId, + NewRoles: newRoles, + }, &reply); err != nil { + return nil, model.NewAppError("RemoteAPI.UpdateChannelMemberRoles", "plugin.rpcplugin.invocation.error", nil, "err="+err.Error(), http.StatusInternalServerError) + } + return reply.ChannelMember, reply.Error +} + +func (api *RemoteAPI) UpdateChannelMemberNotifications(channelId, userId string, notifications map[string]string) (*model.ChannelMember, *model.AppError) { + var reply APIChannelMemberReply + if err := api.client.Call("LocalAPI.UpdateChannelMemberNotifications", &APIUpdateChannelMemberNotificationsArgs{ + ChannelId: channelId, + UserId: userId, + Notifications: notifications, + }, &reply); err != nil { + return nil, model.NewAppError("RemoteAPI.UpdateChannelMemberNotifications", "plugin.rpcplugin.invocation.error", nil, "err="+err.Error(), http.StatusInternalServerError) + } + return reply.ChannelMember, reply.Error +} + +func (api *RemoteAPI) DeleteChannelMember(channelId, userId string) *model.AppError { + var reply APIErrorReply + if err := api.client.Call("LocalAPI.DeleteChannelMember", &APIDeleteChannelMemberArgs{ + ChannelId: channelId, + UserId: userId, + }, &reply); err != nil { + return model.NewAppError("RemoteAPI.DeleteChannelMember", "plugin.rpcplugin.invocation.error", nil, "err="+err.Error(), http.StatusInternalServerError) + } + return reply.Error +} + func (api *RemoteAPI) CreatePost(post *model.Post) (*model.Post, *model.AppError) { var reply APIPostReply if err := api.client.Call("LocalAPI.CreatePost", post, &reply); err != nil { diff --git a/plugin/rpcplugin/api_test.go b/plugin/rpcplugin/api_test.go index 7fe7a0ff9..d7b3733ea 100644 --- a/plugin/rpcplugin/api_test.go +++ b/plugin/rpcplugin/api_test.go @@ -128,11 +128,32 @@ func TestAPI(t *testing.T) { assert.Equal(t, testChannel, channel) assert.Nil(t, err) + api.On("AddChannelMember", testChannel.Id, "theuserid").Return(testChannelMember, nil).Once() + member, err := remote.AddChannelMember(testChannel.Id, "theuserid") + assert.Equal(t, testChannelMember, member) + assert.Nil(t, err) + api.On("GetChannelMember", "thechannelid", "theuserid").Return(testChannelMember, nil).Once() - member, err := remote.GetChannelMember("thechannelid", "theuserid") + member, err = remote.GetChannelMember("thechannelid", "theuserid") + assert.Equal(t, testChannelMember, member) + assert.Nil(t, err) + + api.On("UpdateChannelMemberRoles", testChannel.Id, "theuserid", model.CHANNEL_ADMIN_ROLE_ID).Return(testChannelMember, nil).Once() + member, err = remote.UpdateChannelMemberRoles(testChannel.Id, "theuserid", model.CHANNEL_ADMIN_ROLE_ID) assert.Equal(t, testChannelMember, member) assert.Nil(t, err) + notifications := map[string]string{} + notifications[model.MARK_UNREAD_NOTIFY_PROP] = model.CHANNEL_MARK_UNREAD_MENTION + api.On("UpdateChannelMemberNotifications", testChannel.Id, "theuserid", notifications).Return(testChannelMember, nil).Once() + member, err = remote.UpdateChannelMemberNotifications(testChannel.Id, "theuserid", notifications) + assert.Equal(t, testChannelMember, member) + assert.Nil(t, err) + + api.On("DeleteChannelMember", "thechannelid", "theuserid").Return(nil).Once() + err = remote.DeleteChannelMember("thechannelid", "theuserid") + assert.Nil(t, err) + api.On("CreateUser", mock.AnythingOfType("*model.User")).Return(func(u *model.User) (*model.User, *model.AppError) { u.Id = "theuserid" return u, nil diff --git a/store/sqlstore/upgrade.go b/store/sqlstore/upgrade.go index ae0ea7acd..882ee48ba 100644 --- a/store/sqlstore/upgrade.go +++ b/store/sqlstore/upgrade.go @@ -16,6 +16,7 @@ import ( ) const ( + VERSION_4_10_0 = "4.10.0" VERSION_4_9_0 = "4.9.0" VERSION_4_8_1 = "4.8.1" VERSION_4_8_0 = "4.8.0" @@ -75,6 +76,7 @@ func UpgradeDatabase(sqlStore SqlStore) { UpgradeDatabaseToVersion48(sqlStore) UpgradeDatabaseToVersion481(sqlStore) UpgradeDatabaseToVersion49(sqlStore) + UpgradeDatabaseToVersion410(sqlStore) // If the SchemaVersion is empty this this is the first time it has ran // so lets set it to the current version. @@ -408,3 +410,11 @@ func UpgradeDatabaseToVersion49(sqlStore SqlStore) { saveSchemaVersion(sqlStore, VERSION_4_9_0) } } + +func UpgradeDatabaseToVersion410(sqlStore SqlStore) { + // TODO: Uncomment following condition when version 4.10.0 is released + //if shouldPerformUpgrade(sqlStore, VERSION_4_9_0, VERSION_4_10_0) { + + // saveSchemaVersion(sqlStore, VERSION_4_10_0) + //} +} diff --git a/utils/config.go b/utils/config.go index 13295b362..7032dbad9 100644 --- a/utils/config.go +++ b/utils/config.go @@ -10,6 +10,7 @@ import ( "io/ioutil" "os" "path/filepath" + "reflect" "strconv" "strings" @@ -187,7 +188,7 @@ func NewConfigWatcher(cfgFileName string, f func()) (*ConfigWatcher, error) { if event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create { l4g.Info(fmt.Sprintf("Config file watcher detected a change reloading %v", cfgFileName)) - if _, configReadErr := ReadConfigFile(cfgFileName, true); configReadErr == nil { + if _, _, configReadErr := ReadConfigFile(cfgFileName, true); configReadErr == nil { f() } else { l4g.Error(fmt.Sprintf("Failed to read while watching config file at %v with err=%v", cfgFileName, configReadErr.Error())) @@ -211,18 +212,11 @@ func (w *ConfigWatcher) Close() { } // ReadConfig reads and parses the given configuration. -func ReadConfig(r io.Reader, allowEnvironmentOverrides bool) (*model.Config, error) { - v := viper.New() +func ReadConfig(r io.Reader, allowEnvironmentOverrides bool) (*model.Config, map[string]interface{}, error) { + v := newViper(allowEnvironmentOverrides) - if allowEnvironmentOverrides { - v.SetEnvPrefix("mm") - v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) - v.AutomaticEnv() - } - - v.SetConfigType("json") if err := v.ReadConfig(r); err != nil { - return nil, err + return nil, nil, err } var config model.Config @@ -233,14 +227,151 @@ func ReadConfig(r io.Reader, allowEnvironmentOverrides bool) (*model.Config, err config.PluginSettings = model.PluginSettings{} unmarshalErr = v.UnmarshalKey("pluginsettings", &config.PluginSettings) } - return &config, unmarshalErr + + envConfig := v.EnvSettings() + + var envErr error + if envConfig, envErr = fixEnvSettingsCase(envConfig); envErr != nil { + return nil, nil, envErr + } + + return &config, envConfig, unmarshalErr +} + +func newViper(allowEnvironmentOverrides bool) *viper.Viper { + v := viper.New() + + v.SetConfigType("json") + + if allowEnvironmentOverrides { + v.SetEnvPrefix("mm") + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + v.AutomaticEnv() + } + + // Set zeroed defaults for all the config settings so that Viper knows what environment variables + // it needs to be looking for. The correct defaults will later be applied using Config.SetDefaults. + defaults := flattenStructToMap(structToMap(reflect.TypeOf(model.Config{}))) + + for key, value := range defaults { + v.SetDefault(key, value) + } + + return v +} + +// Converts a struct type into a nested map with keys matching the struct's fields and values +// matching the zeroed value of the corresponding field. +func structToMap(t reflect.Type) (out map[string]interface{}) { + defer func() { + if r := recover(); r != nil { + l4g.Error("Panicked in structToMap. This should never happen. %v", r) + } + }() + + if t.Kind() != reflect.Struct { + // Should never hit this, but this will prevent a panic if that does happen somehow + return nil + } + + out = map[string]interface{}{} + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + + var value interface{} + + switch field.Type.Kind() { + case reflect.Struct: + value = structToMap(field.Type) + case reflect.Ptr: + value = nil + default: + value = reflect.Zero(field.Type).Interface() + } + + out[field.Name] = value + } + + return +} + +// Flattens a nested map so that the result is a single map with keys corresponding to the +// path through the original map. For example, +// { +// "a": { +// "b": 1 +// }, +// "c": "sea" +// } +// would flatten to +// { +// "a.b": 1, +// "c": "sea" +// } +func flattenStructToMap(in map[string]interface{}) map[string]interface{} { + out := make(map[string]interface{}) + + for key, value := range in { + if valueAsMap, ok := value.(map[string]interface{}); ok { + sub := flattenStructToMap(valueAsMap) + + for subKey, subValue := range sub { + out[key+"."+subKey] = subValue + } + } else { + out[key] = value + } + } + + return out +} + +// Fixes the case of the environment variables sent back from Viper since Viper stores +// everything as lower case. +func fixEnvSettingsCase(in map[string]interface{}) (out map[string]interface{}, err error) { + defer func() { + if r := recover(); r != nil { + l4g.Error("Panicked in fixEnvSettingsCase. This should never happen. %v", r) + out = in + } + }() + + var fixCase func(map[string]interface{}, reflect.Type) map[string]interface{} + fixCase = func(in map[string]interface{}, t reflect.Type) map[string]interface{} { + if t.Kind() != reflect.Struct { + // Should never hit this, but this will prevent a panic if that does happen somehow + return nil + } + + out := make(map[string]interface{}, len(in)) + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + + key := field.Name + if value, ok := in[strings.ToLower(key)]; ok { + if valueAsMap, ok := value.(map[string]interface{}); ok { + out[key] = fixCase(valueAsMap, field.Type) + } else { + out[key] = value + } + } + } + + return out + } + + out = fixCase(in, reflect.TypeOf(model.Config{})) + + return } // ReadConfigFile reads and parses the configuration at the given file path. -func ReadConfigFile(path string, allowEnvironmentOverrides bool) (*model.Config, error) { +func ReadConfigFile(path string, allowEnvironmentOverrides bool) (*model.Config, map[string]interface{}, error) { f, err := os.Open(path) if err != nil { - return nil, err + return nil, nil, err } defer f.Close() return ReadConfig(f, allowEnvironmentOverrides) @@ -275,22 +406,24 @@ func EnsureConfigFile(fileName string) (string, error) { // LoadConfig will try to search around for the corresponding config file. It will search // /tmp/fileName then attempt ./config/fileName, then ../config/fileName and last it will look at // fileName. -func LoadConfig(fileName string) (config *model.Config, configPath string, appErr *model.AppError) { +func LoadConfig(fileName string) (*model.Config, string, map[string]interface{}, *model.AppError) { + var configPath string + if fileName != filepath.Base(fileName) { configPath = fileName } else { if path, err := EnsureConfigFile(fileName); err != nil { - appErr = model.NewAppError("LoadConfig", "utils.config.load_config.opening.panic", map[string]interface{}{"Filename": fileName, "Error": err.Error()}, "", 0) - return + appErr := model.NewAppError("LoadConfig", "utils.config.load_config.opening.panic", map[string]interface{}{"Filename": fileName, "Error": err.Error()}, "", 0) + return nil, "", nil, appErr } else { configPath = path } } - config, err := ReadConfigFile(configPath, true) + config, envConfig, err := ReadConfigFile(configPath, true) if err != nil { - appErr = model.NewAppError("LoadConfig", "utils.config.load_config.decoding.panic", map[string]interface{}{"Filename": fileName, "Error": err.Error()}, "", 0) - return + appErr := model.NewAppError("LoadConfig", "utils.config.load_config.decoding.panic", map[string]interface{}{"Filename": fileName, "Error": err.Error()}, "", 0) + return nil, "", nil, appErr } needSave := len(config.SqlSettings.AtRestEncryptKey) == 0 || len(*config.FileSettings.PublicLinkSalt) == 0 || @@ -299,7 +432,7 @@ func LoadConfig(fileName string) (config *model.Config, configPath string, appEr config.SetDefaults() if err := config.IsValid(); err != nil { - return nil, "", err + return nil, "", nil, err } if needSave { @@ -321,7 +454,7 @@ func LoadConfig(fileName string) (config *model.Config, configPath string, appEr } } - return config, configPath, nil + return config, configPath, envConfig, nil } func GenerateClientConfig(c *model.Config, diagnosticId string, license *model.License) map[string]string { @@ -382,6 +515,7 @@ func GenerateClientConfig(c *model.Config, diagnosticId string, license *model.L props["EnableTutorial"] = strconv.FormatBool(*c.ServiceSettings.EnableTutorial) props["ExperimentalEnableDefaultChannelLeaveJoinMessages"] = strconv.FormatBool(*c.ServiceSettings.ExperimentalEnableDefaultChannelLeaveJoinMessages) props["ExperimentalGroupUnreadChannels"] = *c.ServiceSettings.ExperimentalGroupUnreadChannels + props["ExperimentalEnableAutomaticReplies"] = strconv.FormatBool(*c.TeamSettings.ExperimentalEnableAutomaticReplies) props["ExperimentalTimezone"] = strconv.FormatBool(*c.DisplaySettings.ExperimentalTimezone) props["SendEmailNotifications"] = strconv.FormatBool(c.EmailSettings.SendEmailNotifications) diff --git a/utils/config_test.go b/utils/config_test.go index 84e7291b0..fbac577ee 100644 --- a/utils/config_test.go +++ b/utils/config_test.go @@ -18,7 +18,7 @@ import ( func TestConfig(t *testing.T) { TranslationsPreInit() - cfg, _, err := LoadConfig("config.json") + cfg, _, _, err := LoadConfig("config.json") require.Nil(t, err) InitTranslations(cfg.LocalizationSettings) } @@ -50,53 +50,133 @@ func TestFindConfigFile(t *testing.T) { } func TestConfigFromEnviroVars(t *testing.T) { - os.Setenv("MM_TEAMSETTINGS_SITENAME", "From Environment") - os.Setenv("MM_TEAMSETTINGS_CUSTOMBRANDTEXT", "Custom Brand") - os.Setenv("MM_SERVICESETTINGS_ENABLECOMMANDS", "false") - os.Setenv("MM_SERVICESETTINGS_READTIMEOUT", "400") - TranslationsPreInit() - cfg, cfgPath, err := LoadConfig("config.json") - require.Nil(t, err) - if cfg.TeamSettings.SiteName != "From Environment" { - t.Fatal("Couldn't read config from environment var") - } + config := `{ + "ServiceSettings": { + "EnableCommands": true, + "ReadTimeout": 100 + }, + "TeamSettings": { + "SiteName": "Mattermost", + "CustomBrandText": "" + } + }` - if *cfg.TeamSettings.CustomBrandText != "Custom Brand" { - t.Fatal("Couldn't read config from environment var") - } + t.Run("string settings", func(t *testing.T) { + os.Setenv("MM_TEAMSETTINGS_SITENAME", "From Environment") + os.Setenv("MM_TEAMSETTINGS_CUSTOMBRANDTEXT", "Custom Brand") - if *cfg.ServiceSettings.EnableCommands { - t.Fatal("Couldn't read config from environment var") - } + cfg, envCfg, err := ReadConfig(strings.NewReader(config), true) + require.Nil(t, err) - if *cfg.ServiceSettings.ReadTimeout != 400 { - t.Fatal("Couldn't read config from environment var") - } + if cfg.TeamSettings.SiteName != "From Environment" { + t.Fatal("Couldn't read config from environment var") + } - os.Unsetenv("MM_TEAMSETTINGS_SITENAME") - os.Unsetenv("MM_TEAMSETTINGS_CUSTOMBRANDTEXT") - os.Unsetenv("MM_SERVICESETTINGS_ENABLECOMMANDS") - os.Unsetenv("MM_SERVICESETTINGS_READTIMEOUT") + if *cfg.TeamSettings.CustomBrandText != "Custom Brand" { + t.Fatal("Couldn't read config from environment var") + } - cfg.TeamSettings.SiteName = "Mattermost" - *cfg.ServiceSettings.SiteURL = "" - *cfg.ServiceSettings.EnableCommands = true - *cfg.ServiceSettings.ReadTimeout = 300 - SaveConfig(cfgPath, cfg) + if teamSettings, ok := envCfg["TeamSettings"]; !ok { + t.Fatal("TeamSettings is missing from envConfig") + } else if teamSettingsAsMap, ok := teamSettings.(map[string]interface{}); !ok { + t.Fatal("TeamSettings is not a map in envConfig") + } else { + if siteNameInEnv, ok := teamSettingsAsMap["SiteName"].(bool); !ok || !siteNameInEnv { + t.Fatal("SiteName should be in envConfig") + } - cfg, _, err = LoadConfig("config.json") - require.Nil(t, err) + if customBrandTextInEnv, ok := teamSettingsAsMap["CustomBrandText"].(bool); !ok || !customBrandTextInEnv { + t.Fatal("SiteName should be in envConfig") + } + } - if cfg.TeamSettings.SiteName != "Mattermost" { - t.Fatal("should have been reset") - } + os.Unsetenv("MM_TEAMSETTINGS_SITENAME") + os.Unsetenv("MM_TEAMSETTINGS_CUSTOMBRANDTEXT") + + cfg, envCfg, err = ReadConfig(strings.NewReader(config), true) + require.Nil(t, err) + + if cfg.TeamSettings.SiteName != "Mattermost" { + t.Fatal("should have been reset") + } + + if _, ok := envCfg["TeamSettings"]; ok { + t.Fatal("TeamSettings should be missing from envConfig") + } + }) + + t.Run("boolean setting", func(t *testing.T) { + os.Setenv("MM_SERVICESETTINGS_ENABLECOMMANDS", "false") + defer os.Unsetenv("MM_SERVICESETTINGS_ENABLECOMMANDS") + + cfg, envCfg, err := ReadConfig(strings.NewReader(config), true) + require.Nil(t, err) + + if *cfg.ServiceSettings.EnableCommands { + t.Fatal("Couldn't read config from environment var") + } + + if serviceSettings, ok := envCfg["ServiceSettings"]; !ok { + t.Fatal("ServiceSettings is missing from envConfig") + } else if serviceSettingsAsMap, ok := serviceSettings.(map[string]interface{}); !ok { + t.Fatal("ServiceSettings is not a map in envConfig") + } else { + if enableCommandsInEnv, ok := serviceSettingsAsMap["EnableCommands"].(bool); !ok || !enableCommandsInEnv { + t.Fatal("EnableCommands should be in envConfig") + } + } + }) + + t.Run("integer setting", func(t *testing.T) { + os.Setenv("MM_SERVICESETTINGS_READTIMEOUT", "400") + defer os.Unsetenv("MM_SERVICESETTINGS_READTIMEOUT") + + cfg, envCfg, err := ReadConfig(strings.NewReader(config), true) + require.Nil(t, err) + + if *cfg.ServiceSettings.ReadTimeout != 400 { + t.Fatal("Couldn't read config from environment var") + } + + if serviceSettings, ok := envCfg["ServiceSettings"]; !ok { + t.Fatal("ServiceSettings is missing from envConfig") + } else if serviceSettingsAsMap, ok := serviceSettings.(map[string]interface{}); !ok { + t.Fatal("ServiceSettings is not a map in envConfig") + } else { + if readTimeoutInEnv, ok := serviceSettingsAsMap["ReadTimeout"].(bool); !ok || !readTimeoutInEnv { + t.Fatal("ReadTimeout should be in envConfig") + } + } + }) + + t.Run("setting missing from config.json", func(t *testing.T) { + os.Setenv("MM_SERVICESETTINGS_SITEURL", "https://example.com") + defer os.Unsetenv("MM_SERVICESETTINGS_SITEURL") + + cfg, envCfg, err := ReadConfig(strings.NewReader(config), true) + require.Nil(t, err) + + if *cfg.ServiceSettings.SiteURL != "https://example.com" { + t.Fatal("Couldn't read config from environment var") + } + + if serviceSettings, ok := envCfg["ServiceSettings"]; !ok { + t.Fatal("ServiceSettings is missing from envConfig") + } else if serviceSettingsAsMap, ok := serviceSettings.(map[string]interface{}); !ok { + t.Fatal("ServiceSettings is not a map in envConfig") + } else { + if siteURLInEnv, ok := serviceSettingsAsMap["SiteURL"].(bool); !ok || !siteURLInEnv { + t.Fatal("SiteURL should be in envConfig") + } + } + }) } func TestValidateLocales(t *testing.T) { TranslationsPreInit() - cfg, _, err := LoadConfig("config.json") + cfg, _, _, err := LoadConfig("config.json") require.Nil(t, err) *cfg.LocalizationSettings.DefaultServerLocale = "en" @@ -294,18 +374,6 @@ func TestGetClientConfig(t *testing.T) { } }) } - -} - -func TestReadConfig(t *testing.T) { - config, err := ReadConfig(strings.NewReader(`{ - "ServiceSettings": { - "SiteURL": "http://foo.bar" - } - }`), false) - require.NoError(t, err) - - assert.Equal(t, "http://foo.bar", *config.ServiceSettings.SiteURL) } func sToP(s string) *string { diff --git a/utils/mail_test.go b/utils/mail_test.go index 65b89c240..6bd8e7044 100644 --- a/utils/mail_test.go +++ b/utils/mail_test.go @@ -13,7 +13,7 @@ import ( ) func TestMailConnectionFromConfig(t *testing.T) { - cfg, _, err := LoadConfig("config.json") + cfg, _, _, err := LoadConfig("config.json") require.Nil(t, err) if conn, err := ConnectToSMTPServer(cfg); err != nil { @@ -36,7 +36,7 @@ func TestMailConnectionFromConfig(t *testing.T) { } func TestMailConnectionAdvanced(t *testing.T) { - cfg, _, err := LoadConfig("config.json") + cfg, _, _, err := LoadConfig("config.json") require.Nil(t, err) if conn, err := ConnectToSMTPServerAdvanced( @@ -86,7 +86,7 @@ func TestMailConnectionAdvanced(t *testing.T) { } func TestSendMailUsingConfig(t *testing.T) { - cfg, _, err := LoadConfig("config.json") + cfg, _, _, err := LoadConfig("config.json") require.Nil(t, err) T = GetUserTranslations("en") diff --git a/vendor/github.com/spf13/viper/nohup.out b/vendor/github.com/spf13/viper/nohup.out deleted file mode 100644 index 8973bf27b..000000000 --- a/vendor/github.com/spf13/viper/nohup.out +++ /dev/null @@ -1 +0,0 @@ -QProcess::start: Process is already running diff --git a/vendor/github.com/spf13/viper/viper.go b/vendor/github.com/spf13/viper/viper.go index ad8a03729..b9e165695 100644 --- a/vendor/github.com/spf13/viper/viper.go +++ b/vendor/github.com/spf13/viper/viper.go @@ -1675,6 +1675,26 @@ func (v *Viper) AllSettings() map[string]interface{} { return m } +// EnvSettings returns a map[string]interface{} containing all settings set +// through environment variables. +func EnvSettings() map[string]interface{} { return v.EnvSettings() } +func (v *Viper) EnvSettings() map[string]interface{} { + m := map[string]interface{}{} + // start from the list of keys, and construct the map one value at a time + for _, k := range v.AllKeys() { + value := v.getEnv(v.mergeWithEnvPrefix(k)) + if value == "" { + continue + } + path := strings.Split(k, v.keyDelim) + lastKey := strings.ToLower(path[len(path)-1]) + deepestMap := deepSearch(m, path[0:len(path)-1]) + // set innermost value + deepestMap[lastKey] = true + } + return m +} + // SetFs sets the filesystem to use to read configuration. func SetFs(fs afero.Fs) { v.SetFs(fs) } func (v *Viper) SetFs(fs afero.Fs) { @@ -1720,18 +1740,14 @@ func (v *Viper) getConfigType() string { } func (v *Viper) getConfigFile() (string, error) { - // if explicitly set, then use it - if v.configFile != "" { - return v.configFile, nil - } - - cf, err := v.findConfigFile() - if err != nil { - return "", err + if v.configFile == "" { + cf, err := v.findConfigFile() + if err != nil { + return "", err + } + v.configFile = cf } - - v.configFile = cf - return v.getConfigFile() + return v.configFile, nil } func (v *Viper) searchInPath(in string) (filename string) { diff --git a/web/web_test.go b/web/web_test.go index 60122f049..09460b3b0 100644 --- a/web/web_test.go +++ b/web/web_test.go @@ -186,8 +186,11 @@ func TestCheckClientCompatability(t *testing.T) { {"MM App 3.7.1", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Mattermost/3.7.1 Chrome/56.0.2924.87 Electron/1.6.11 Safari/537.36", true}, {"Franz 4.0.4", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Franz/4.0.4 Chrome/52.0.2743.82 Electron/1.3.1 Safari/537.36", true}, {"Edge 14", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.79 Safari/537.36 Edge/14.14393", true}, - {"Internet Explorer 11", "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko", true}, {"Internet Explorer 9", "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 7.1; Trident/5.0", false}, + {"Internet Explorer 11", "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko", true}, + {"Internet Explorer 11 2", "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; .NET4.0C; .NET4.0E; .NET CLR 2.0.50727; .NET CLR 3.0.30729; .NET CLR 3.5.30729; Zoom 3.6.0; rv:11.0) like Gecko", true}, + {"Internet Explorer 11 (Compatibility Mode) 1", "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 10.0; WOW64; Trident/7.0; .NET4.0C; .NET4.0E; .NET CLR 2.0.50727; .NET CLR 3.0.30729; .NET CLR 3.5.30729; .NET CLR 1.1.4322; InfoPath.3; Zoom 3.6.0)", false}, + {"Internet Explorer 11 (Compatibility Mode) 2", "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 10.0; WOW64; Trident/7.0; .NET4.0C; .NET4.0E; .NET CLR 2.0.50727; .NET CLR 3.0.30729; .NET CLR 3.5.30729; Zoom 3.6.0)", false}, {"Safari 9", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Safari/604.1.38", true}, {"Safari 8", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_4) AppleWebKit/600.7.12 (KHTML, like Gecko) Version/8.0.7 Safari/600.7.12", false}, {"Safari Mobile", "Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B137 Safari/601.1", true}, |