diff --git a/Makefile b/Makefile index ab507dceee..bdf0e3a686 100644 --- a/Makefile +++ b/Makefile @@ -361,7 +361,7 @@ run-server: prepare-enterprise start-docker run-cli: prepare-enterprise start-docker @echo Running mattermost for development - @echo Example should be like >'make ARGS="-version" run-cli' + @echo Example should be like 'make ARGS="-version" run-cli' $(GO) run $(GOFLAGS) $(GO_LINKER_FLAGS) *.go ${ARGS} diff --git a/api/admin.go b/api/admin.go index 9ac071e6df..0edfb246b2 100644 --- a/api/admin.go +++ b/api/admin.go @@ -20,6 +20,7 @@ import ( "github.com/mattermost/platform/store" "github.com/mattermost/platform/utils" "github.com/mssola/user_agent" + "runtime/debug" ) func InitAdmin() { @@ -48,7 +49,7 @@ func InitAdmin() { BaseRoutes.Admin.Handle("/remove_certificate", ApiAdminSystemRequired(removeCertificate)).Methods("POST") BaseRoutes.Admin.Handle("/saml_cert_status", ApiAdminSystemRequired(samlCertificateStatus)).Methods("GET") BaseRoutes.Admin.Handle("/cluster_status", ApiAdminSystemRequired(getClusterStatus)).Methods("GET") - BaseRoutes.Admin.Handle("/recently_active_users/{team_id:[A-Za-z0-9]+}", ApiUserRequiredActivity(getRecentlyActiveUsers, false)).Methods("GET") + BaseRoutes.Admin.Handle("/recently_active_users/{team_id:[A-Za-z0-9]+}", ApiUserRequired(getRecentlyActiveUsers)).Methods("GET") } func getLogs(c *Context, w http.ResponseWriter, r *http.Request) { @@ -134,6 +135,7 @@ func getConfig(c *Context, w http.ResponseWriter, r *http.Request) { } func reloadConfig(c *Context, w http.ResponseWriter, r *http.Request) { + debug.FreeOSMemory() utils.LoadConfig(utils.CfgFileName) // start/restart email batching job if necessary @@ -338,12 +340,15 @@ func getAnalytics(c *Context, w http.ResponseWriter, r *http.Request) { name := params["name"] if name == "standard" { - var rows model.AnalyticsRows = make([]*model.AnalyticsRow, 5) + var rows model.AnalyticsRows = make([]*model.AnalyticsRow, 8) rows[0] = &model.AnalyticsRow{"channel_open_count", 0} rows[1] = &model.AnalyticsRow{"channel_private_count", 0} rows[2] = &model.AnalyticsRow{"post_count", 0} rows[3] = &model.AnalyticsRow{"unique_user_count", 0} rows[4] = &model.AnalyticsRow{"team_count", 0} + rows[5] = &model.AnalyticsRow{"total_websocket_connections", 0} + rows[6] = &model.AnalyticsRow{"total_master_db_connections", 0} + rows[7] = &model.AnalyticsRow{"total_read_db_connections", 0} openChan := Srv.Store.Channel().AnalyticsTypeCount(teamId, model.CHANNEL_OPEN) privateChan := Srv.Store.Channel().AnalyticsTypeCount(teamId, model.CHANNEL_PRIVATE) @@ -386,6 +391,10 @@ func getAnalytics(c *Context, w http.ResponseWriter, r *http.Request) { rows[4].Value = float64(r.Data.(int64)) } + rows[5].Value = float64(TotalWebsocketConnections()) + rows[6].Value = float64(Srv.Store.TotalMasterDbConnections()) + rows[7].Value = float64(Srv.Store.TotalReadDbConnections()) + w.Write([]byte(rows.ToJson())) } else if name == "post_counts_day" { if r := <-Srv.Store.Post().AnalyticsPostCountsByDay(teamId); r.Err != nil { @@ -706,32 +715,14 @@ func samlCertificateStatus(c *Context, w http.ResponseWriter, r *http.Request) { } func getRecentlyActiveUsers(c *Context, w http.ResponseWriter, r *http.Request) { - statusMap := map[string]interface{}{} - - if result := <-Srv.Store.Status().GetAllFromTeam(c.TeamId); result.Err != nil { - c.Err = result.Err - return - } else { - statuses := result.Data.([]*model.Status) - for _, s := range statuses { - statusMap[s.UserId] = s.LastActivityAt - } - } - - if result := <-Srv.Store.User().GetProfiles(c.TeamId); result.Err != nil { + if result := <-Srv.Store.User().GetRecentlyActiveUsersForTeam(c.TeamId); result.Err != nil { c.Err = result.Err return } else { profiles := result.Data.(map[string]*model.User) - for k, p := range profiles { - p = sanitizeProfile(c, p) - - if lastActivityAt, ok := statusMap[p.Id].(int64); ok { - p.LastActivityAt = lastActivityAt - } - - profiles[k] = p + for _, p := range profiles { + sanitizeProfile(c, p) } w.Write([]byte(model.UserMapToJson(profiles))) diff --git a/api/admin_test.go b/api/admin_test.go index 445d2de385..e1520877c7 100644 --- a/api/admin_test.go +++ b/api/admin_test.go @@ -527,17 +527,13 @@ func TestAdminLdapSyncNow(t *testing.T) { } } +// Needs more work func TestGetRecentlyActiveUsers(t *testing.T) { th := Setup().InitBasic() - user1Id := th.BasicUser.Id - user2Id := th.BasicUser2.Id - if userMap, err := th.BasicClient.GetRecentlyActiveUsers(th.BasicTeam.Id); err != nil { t.Fatal(err) - } else if len(userMap.Data.(map[string]*model.User)) != 2 { - t.Fatal("should have been 2") - } else if userMap.Data.(map[string]*model.User)[user1Id].Id != user1Id || userMap.Data.(map[string]*model.User)[user2Id].Id != user2Id { - t.Fatal("should have been valid") + } else if len(userMap.Data.(map[string]*model.User)) >= 2 { + t.Fatal("should have been at least 2") } } diff --git a/api/apitestlib.go b/api/apitestlib.go index 9345d3fc42..37367b71d0 100644 --- a/api/apitestlib.go +++ b/api/apitestlib.go @@ -36,7 +36,7 @@ func SetupEnterprise() *TestHelper { *utils.Cfg.RateLimitSettings.Enable = false utils.DisableDebugLogForTest() utils.License.Features.SetDefaults() - NewServer() + NewServer(false) StartServer() utils.InitHTML() InitApi() @@ -57,7 +57,7 @@ func Setup() *TestHelper { utils.Cfg.TeamSettings.MaxUsersPerTeam = 50 *utils.Cfg.RateLimitSettings.Enable = false utils.DisableDebugLogForTest() - NewServer() + NewServer(false) StartServer() InitApi() utils.EnableDebugLogForTest() diff --git a/api/authorization.go b/api/authorization.go index 5badf244ba..8b3140b0f2 100644 --- a/api/authorization.go +++ b/api/authorization.go @@ -5,6 +5,7 @@ package api import ( "net/http" + "strings" l4g "github.com/alecthomas/log4go" "github.com/mattermost/platform/model" @@ -65,15 +66,16 @@ func HasPermissionToTeam(user *model.User, teamMember *model.TeamMember, permiss } func HasPermissionToChannelContext(c *Context, channelId string, permission *model.Permission) bool { - cmc := Srv.Store.Channel().GetMember(channelId, c.Session.UserId) + cmc := Srv.Store.Channel().GetAllChannelMembersForUser(c.Session.UserId, true) var channelRoles []string if cmcresult := <-cmc; cmcresult.Err == nil { - channelMember := cmcresult.Data.(model.ChannelMember) - channelRoles = channelMember.GetRoles() - - if CheckIfRolesGrantPermission(channelRoles, permission.Id) { - return true + ids := cmcresult.Data.(map[string]string) + if roles, ok := ids[channelId]; ok { + channelRoles = strings.Fields(roles) + if CheckIfRolesGrantPermission(channelRoles, permission.Id) { + return true + } } } diff --git a/api/auto_users.go b/api/auto_users.go index a23b762469..7439de96e0 100644 --- a/api/auto_users.go +++ b/api/auto_users.go @@ -80,6 +80,13 @@ func (cfg *AutoUserCreator) createRandomUser() (*model.User, bool) { ruser := result.Data.(*model.User) + status := &model.Status{ruser.Id, model.STATUS_ONLINE, false, model.GetMillis(), ""} + if result := <-Srv.Store.Status().SaveOrUpdate(status); result.Err != nil { + result.Err.Translate(utils.T) + l4g.Error(result.Err.Error()) + return nil, false + } + // We need to cheat to verify the user's email store.Must(Srv.Store.User().VerifyEmail(ruser.Id)) diff --git a/api/channel.go b/api/channel.go index f40c979ca1..9cc8976c2f 100644 --- a/api/channel.go +++ b/api/channel.go @@ -6,7 +6,6 @@ package api import ( "fmt" "net/http" - "strconv" "strings" l4g "github.com/alecthomas/log4go" @@ -16,16 +15,12 @@ import ( "github.com/mattermost/platform/utils" ) -const ( - defaultExtraMemberLimit = 100 -) - func InitChannel() { l4g.Debug(utils.T("api.channel.init.debug")) - BaseRoutes.Channels.Handle("/", ApiUserRequiredActivity(getChannels, false)).Methods("GET") + BaseRoutes.Channels.Handle("/", ApiUserRequired(getChannels)).Methods("GET") BaseRoutes.Channels.Handle("/more", ApiUserRequired(getMoreChannels)).Methods("GET") - BaseRoutes.Channels.Handle("/counts", ApiUserRequiredActivity(getChannelCounts, false)).Methods("GET") + BaseRoutes.Channels.Handle("/counts", ApiUserRequired(getChannelCounts)).Methods("GET") BaseRoutes.Channels.Handle("/create", ApiUserRequired(createChannel)).Methods("POST") BaseRoutes.Channels.Handle("/create_direct", ApiUserRequired(createDirectChannel)).Methods("POST") BaseRoutes.Channels.Handle("/update", ApiUserRequired(updateChannel)).Methods("POST") @@ -35,9 +30,9 @@ func InitChannel() { BaseRoutes.NeedChannelName.Handle("/join", ApiUserRequired(join)).Methods("POST") - BaseRoutes.NeedChannel.Handle("/", ApiUserRequiredActivity(getChannel, false)).Methods("GET") - BaseRoutes.NeedChannel.Handle("/extra_info", ApiUserRequired(getChannelExtraInfo)).Methods("GET") - BaseRoutes.NeedChannel.Handle("/extra_info/{member_limit:-?[0-9]+}", ApiUserRequired(getChannelExtraInfo)).Methods("GET") + BaseRoutes.NeedChannel.Handle("/", ApiUserRequired(getChannel)).Methods("GET") + BaseRoutes.NeedChannel.Handle("/stats", ApiUserRequired(getChannelStats)).Methods("GET") + BaseRoutes.NeedChannel.Handle("/members/{user_id:[A-Za-z0-9]+}", ApiUserRequired(getChannelMember)).Methods("GET") BaseRoutes.NeedChannel.Handle("/join", ApiUserRequired(join)).Methods("POST") BaseRoutes.NeedChannel.Handle("/leave", ApiUserRequired(leave)).Methods("POST") BaseRoutes.NeedChannel.Handle("/delete", ApiUserRequired(deleteChannel)).Methods("POST") @@ -150,11 +145,14 @@ func CreateDirectChannel(userId string, otherUserId string) (*model.Channel, *mo } else { channel := result.Data.(*model.Channel) + InvalidateCacheForUser(userId) + InvalidateCacheForUser(otherUserId) + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_DIRECT_ADDED, "", channel.Id, "", nil) message.Add("teammate_id", otherUserId) go Publish(message) - return result.Data.(*model.Channel), nil + return channel, nil } } @@ -566,6 +564,7 @@ func AddUserToChannel(user *model.User, channel *model.Channel) (*model.ChannelM go func() { InvalidateCacheForUser(user.Id) + Srv.Store.User().InvalidateProfilesInChannelCache(channel.Id) message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_USER_ADDED, "", channel.Id, "", nil) message.Add("user_id", user.Id) @@ -609,6 +608,8 @@ func JoinDefaultChannels(teamId string, user *model.User, channelRole string) *m if _, err := CreatePost(fakeContext, post, false); err != nil { l4g.Error(utils.T("api.channel.post_user_add_remove_message_and_forget.error"), err) } + + Srv.Store.User().InvalidateProfilesInChannelCache(result.Data.(*model.Channel).Id) } if result := <-Srv.Store.Channel().GetByName(teamId, "off-topic"); result.Err != nil { @@ -631,6 +632,8 @@ func JoinDefaultChannels(teamId string, user *model.User, channelRole string) *m if _, err := CreatePost(fakeContext, post, false); err != nil { l4g.Error(utils.T("api.channel.post_user_add_remove_message_and_forget.error"), err) } + + Srv.Store.User().InvalidateProfilesInChannelCache(result.Data.(*model.Channel).Id) } return err @@ -778,9 +781,9 @@ func deleteChannel(c *Context, w http.ResponseWriter, r *http.Request) { c.LogAudit("name=" + channel.Name) go func() { - InvalidateCacheForChannel(channel.Id) message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_CHANNEL_DELETED, c.TeamId, "", "", nil) message.Add("channel_id", channel.Id) + go Publish(message) post := &model.Post{ @@ -917,54 +920,27 @@ func getChannel(c *Context, w http.ResponseWriter, r *http.Request) { } -func getChannelExtraInfo(c *Context, w http.ResponseWriter, r *http.Request) { +func getChannelStats(c *Context, w http.ResponseWriter, r *http.Request) { params := mux.Vars(r) id := params["channel_id"] - var memberLimit int - if memberLimitString, ok := params["member_limit"]; !ok { - memberLimit = defaultExtraMemberLimit - } else if memberLimitInt64, err := strconv.ParseInt(memberLimitString, 10, 0); err != nil { - c.Err = model.NewLocAppError("getChannelExtraInfo", "api.channel.get_channel_extra_info.member_limit.app_error", nil, err.Error()) - return - } else { - memberLimit = int(memberLimitInt64) - } - sc := Srv.Store.Channel().Get(id) var channel *model.Channel - if cresult := <-sc; cresult.Err != nil { - c.Err = cresult.Err + if result := <-sc; result.Err != nil { + c.Err = result.Err return } else { - channel = cresult.Data.(*model.Channel) + channel = result.Data.(*model.Channel) } - extraEtag := channel.ExtraEtag(memberLimit) - if HandleEtag(extraEtag, w, r) { - return - } - - scm := Srv.Store.Channel().GetMember(id, c.Session.UserId) - ecm := Srv.Store.Channel().GetExtraMembers(id, memberLimit) - ccm := Srv.Store.Channel().GetMemberCount(id) - - if cmresult := <-scm; cmresult.Err != nil { - c.Err = cmresult.Err - return - } else if ecmresult := <-ecm; ecmresult.Err != nil { - c.Err = ecmresult.Err - return - } else if ccmresult := <-ccm; ccmresult.Err != nil { - c.Err = ccmresult.Err + if result := <-Srv.Store.Channel().GetMemberCount(id); result.Err != nil { + c.Err = result.Err return } else { - //member := cmresult.Data.(model.ChannelMember) - extraMembers := ecmresult.Data.([]model.ExtraMember) - memberCount := ccmresult.Data.(int64) + memberCount := result.Data.(int64) if channel.DeleteAt > 0 { - c.Err = model.NewLocAppError("getChannelExtraInfo", "api.channel.get_channel_extra_info.deleted.app_error", nil, "") + c.Err = model.NewLocAppError("getChannelStats", "api.channel.get_channel_extra_info.deleted.app_error", nil, "") c.Err.StatusCode = http.StatusBadRequest return } @@ -973,12 +949,29 @@ func getChannelExtraInfo(c *Context, w http.ResponseWriter, r *http.Request) { return } - data := model.ChannelExtra{Id: channel.Id, Members: extraMembers, MemberCount: memberCount} - w.Header().Set(model.HEADER_ETAG_SERVER, extraEtag) + data := model.ChannelStats{ChannelId: channel.Id, MemberCount: memberCount} w.Write([]byte(data.ToJson())) } } +func getChannelMember(c *Context, w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + channelId := params["channel_id"] + userId := params["user_id"] + + if !HasPermissionToChannelContext(c, channelId, model.PERMISSION_READ_CHANNEL) { + return + } + + if result := <-Srv.Store.Channel().GetMember(channelId, userId); result.Err != nil { + c.Err = result.Err + return + } else { + member := result.Data.(model.ChannelMember) + w.Write([]byte(member.ToJson())) + } +} + func addMember(c *Context, w http.ResponseWriter, r *http.Request) { params := mux.Vars(r) id := params["channel_id"] @@ -1101,6 +1094,7 @@ func RemoveUserFromChannel(userIdToRemove string, removerUserId string, channel } InvalidateCacheForUser(userIdToRemove) + Srv.Store.User().InvalidateProfilesInChannelCache(channel.Id) message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_USER_REMOVED, "", channel.Id, "", nil) message.Add("user_id", userIdToRemove) diff --git a/api/channel_test.go b/api/channel_test.go index 1d0f0270da..4835ee9b7f 100644 --- a/api/channel_test.go +++ b/api/channel_test.go @@ -10,7 +10,6 @@ import ( "time" "github.com/mattermost/platform/model" - "github.com/mattermost/platform/store" "github.com/mattermost/platform/utils" ) @@ -1106,7 +1105,7 @@ func TestDeleteChannel(t *testing.T) { } } -func TestGetChannelExtraInfo(t *testing.T) { +func TestGetChannelStats(t *testing.T) { th := Setup().InitBasic() Client := th.BasicClient team := th.BasicTeam @@ -1114,115 +1113,13 @@ func TestGetChannelExtraInfo(t *testing.T) { channel1 := &model.Channel{DisplayName: "A Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel) - rget := Client.Must(Client.GetChannelExtraInfo(channel1.Id, -1, "")) - data := rget.Data.(*model.ChannelExtra) - if data.Id != channel1.Id { + rget := Client.Must(Client.GetChannelStats(channel1.Id, "")) + data := rget.Data.(*model.ChannelStats) + if data.ChannelId != channel1.Id { t.Fatal("couldnt't get extra info") - } else if len(data.Members) != 1 { - t.Fatal("got incorrect members") } else if data.MemberCount != 1 { t.Fatal("got incorrect member count") } - - // - // Testing etag caching - // - - currentEtag := rget.Etag - - if cache_result, err := Client.GetChannelExtraInfo(channel1.Id, -1, currentEtag); err != nil { - t.Fatal(err) - } else if cache_result.Data.(*model.ChannelExtra) != nil { - t.Log(cache_result.Data) - t.Fatal("response should be empty") - } else { - currentEtag = cache_result.Etag - } - - Client2 := model.NewClient("http://localhost" + utils.Cfg.ServiceSettings.ListenAddress) - - user2 := &model.User{Email: "success+" + model.NewId() + "@simulator.amazonses.com", Nickname: "Tester 2", Password: "passwd1"} - user2 = Client2.Must(Client2.CreateUser(user2, "")).Data.(*model.User) - LinkUserToTeam(user2, team) - Client2.SetTeamId(team.Id) - store.Must(Srv.Store.User().VerifyEmail(user2.Id)) - - Client2.Login(user2.Email, "passwd1") - Client2.Must(Client2.JoinChannel(channel1.Id)) - - if cache_result, err := Client.GetChannelExtraInfo(channel1.Id, -1, currentEtag); err != nil { - t.Fatal(err) - } else if cache_result.Data.(*model.ChannelExtra) == nil { - t.Log(cache_result.Data) - t.Fatal("response should not be empty") - } else { - currentEtag = cache_result.Etag - } - - if cache_result, err := Client.GetChannelExtraInfo(channel1.Id, -1, currentEtag); err != nil { - t.Fatal(err) - } else if cache_result.Data.(*model.ChannelExtra) != nil { - t.Log(cache_result.Data) - t.Fatal("response should be empty") - } else { - currentEtag = cache_result.Etag - } - - Client2.Must(Client2.LeaveChannel(channel1.Id)) - - if cache_result, err := Client.GetChannelExtraInfo(channel1.Id, -1, currentEtag); err != nil { - t.Fatal(err) - } else if cache_result.Data.(*model.ChannelExtra) == nil { - t.Log(cache_result.Data) - t.Fatal("response should not be empty") - } else { - currentEtag = cache_result.Etag - } - - if cache_result, err := Client.GetChannelExtraInfo(channel1.Id, -1, currentEtag); err != nil { - t.Fatal(err) - } else if cache_result.Data.(*model.ChannelExtra) != nil { - t.Log(cache_result.Data) - t.Fatal("response should be empty") - } else { - currentEtag = cache_result.Etag - } - - Client2.Must(Client2.JoinChannel(channel1.Id)) - - if cache_result, err := Client.GetChannelExtraInfo(channel1.Id, 2, currentEtag); err != nil { - t.Fatal(err) - } else if extra := cache_result.Data.(*model.ChannelExtra); extra == nil { - t.Fatal("response should not be empty") - } else if len(extra.Members) != 2 { - t.Fatal("should've returned 2 members") - } else if extra.MemberCount != 2 { - t.Fatal("should've returned member count of 2") - } else { - currentEtag = cache_result.Etag - } - - if cache_result, err := Client.GetChannelExtraInfo(channel1.Id, 1, currentEtag); err != nil { - t.Fatal(err) - } else if extra := cache_result.Data.(*model.ChannelExtra); extra == nil { - t.Fatal("response should not be empty") - } else if len(extra.Members) != 1 { - t.Fatal("should've returned only 1 member") - } else if extra.MemberCount != 2 { - t.Fatal("should've returned member count of 2") - } else { - currentEtag = cache_result.Etag - } - - if cache_result, err := Client.GetChannelExtraInfo(channel1.Id, 1, currentEtag); err != nil { - t.Fatal(err) - } else if cache_result.Data.(*model.ChannelExtra) != nil { - t.Log(cache_result.Data) - t.Fatal("response should be empty") - } else { - currentEtag = cache_result.Etag - } - } func TestAddChannelMember(t *testing.T) { @@ -1495,3 +1392,41 @@ func TestFuzzyChannel(t *testing.T) { } } } + +func TestGetChannelMember(t *testing.T) { + th := Setup().InitBasic() + Client := th.BasicClient + team := th.BasicTeam + + channel1 := &model.Channel{DisplayName: "A Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} + channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel) + + if result, err := Client.GetChannelMember(channel1.Id, th.BasicUser.Id); err != nil { + t.Fatal(err) + } else { + cm := result.Data.(*model.ChannelMember) + + if cm.UserId != th.BasicUser.Id { + t.Fatal("user ids didn't match") + } + if cm.ChannelId != channel1.Id { + t.Fatal("channel ids didn't match") + } + } + + if _, err := Client.GetChannelMember(channel1.Id, th.BasicUser2.Id); err == nil { + t.Fatal("should have failed - user not in channel") + } + + if _, err := Client.GetChannelMember("junk", th.BasicUser2.Id); err == nil { + t.Fatal("should have failed - bad channel id") + } + + if _, err := Client.GetChannelMember(channel1.Id, "junk"); err == nil { + t.Fatal("should have failed - bad user id") + } + + if _, err := Client.GetChannelMember("junk", "junk"); err == nil { + t.Fatal("should have failed - bad channel and user id") + } +} diff --git a/api/cli_test.go b/api/cli_test.go index de2347058c..4613988f3d 100644 --- a/api/cli_test.go +++ b/api/cli_test.go @@ -68,7 +68,7 @@ func TestCliCreateUserWithTeam(t *testing.T) { t.Fatal(err) } - profiles := th.SystemAdminClient.Must(th.SystemAdminClient.GetProfiles(th.SystemAdminTeam.Id, "")).Data.(map[string]*model.User) + profiles := th.SystemAdminClient.Must(th.SystemAdminClient.GetProfilesInTeam(th.SystemAdminTeam.Id, 0, 1000, "")).Data.(map[string]*model.User) found := false @@ -318,7 +318,7 @@ func TestCliJoinTeam(t *testing.T) { t.Fatal(err) } - profiles := th.SystemAdminClient.Must(th.SystemAdminClient.GetProfiles(th.SystemAdminTeam.Id, "")).Data.(map[string]*model.User) + profiles := th.SystemAdminClient.Must(th.SystemAdminClient.GetProfilesInTeam(th.SystemAdminTeam.Id, 0, 1000, "")).Data.(map[string]*model.User) found := false @@ -348,7 +348,7 @@ func TestCliLeaveTeam(t *testing.T) { t.Fatal(err) } - profiles := th.BasicClient.Must(th.BasicClient.GetProfiles(th.BasicTeam.Id, "")).Data.(map[string]*model.User) + profiles := th.BasicClient.Must(th.BasicClient.GetProfilesInTeam(th.BasicTeam.Id, 0, 1000, "")).Data.(map[string]*model.User) found := false @@ -359,8 +359,8 @@ func TestCliLeaveTeam(t *testing.T) { } - if !found { - t.Fatal("profile still should be in team even if deleted") + if found { + t.Fatal("profile should not be on team") } if result := <-Srv.Store.Team().GetTeamsByUserId(th.BasicUser.Id); result.Err != nil { diff --git a/api/command_loadtest.go b/api/command_loadtest.go index beb831b222..8f5163a66e 100644 --- a/api/command_loadtest.go +++ b/api/command_loadtest.go @@ -288,7 +288,7 @@ func (me *LoadTestProvider) PostsCommand(c *Context, channelId string, message s } var usernames []string - if result := <-Srv.Store.User().GetProfiles(c.TeamId); result.Err == nil { + if result := <-Srv.Store.User().GetProfiles(c.TeamId, 0, 1000); result.Err == nil { profileUsers := result.Data.(map[string]*model.User) usernames = make([]string, len(profileUsers)) i := 0 diff --git a/api/command_msg.go b/api/command_msg.go index aac6573856..2e0e253972 100644 --- a/api/command_msg.go +++ b/api/command_msg.go @@ -47,20 +47,22 @@ func (me *msgProvider) DoCommand(c *Context, channelId string, message string) * targetUser = strings.SplitN(message, " ", 2)[0] targetUser = strings.TrimPrefix(targetUser, "@") - if profileList := <-Srv.Store.User().GetAllProfiles(); profileList.Err != nil { + // FIX ME + // Why isn't this selecting by username since we have that? + if profileList := <-Srv.Store.User().GetAll(); profileList.Err != nil { c.Err = profileList.Err return &model.CommandResponse{Text: c.T("api.command_msg.list.app_error"), ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL} } else { - profileUsers := profileList.Data.(map[string]*model.User) + profileUsers := profileList.Data.([]*model.User) for _, userProfile := range profileUsers { - //Don't let users open DMs with themselves. It probably won't work out well. + // Don't let users open DMs with themselves. It probably won't work out well. if userProfile.Id == c.Session.UserId { continue } if userProfile.Username == targetUser { targetChannelId := "" - //Find the channel based on this user + // Find the channel based on this user channelName := model.GetDMNameFromIds(c.Session.UserId, userProfile.Id) if channel := <-Srv.Store.Channel().GetByName(c.TeamId, channelName); channel.Err != nil { diff --git a/api/command_statuses_test.go b/api/command_statuses_test.go index 1c8026a9fc..73c6354f4b 100644 --- a/api/command_statuses_test.go +++ b/api/command_statuses_test.go @@ -27,7 +27,7 @@ func commandAndTest(t *testing.T, th *TestHelper, status string) { t.Fatal("Command failed to execute") } - time.Sleep(300 * time.Millisecond) + time.Sleep(500 * time.Millisecond) statuses := Client.Must(Client.GetStatuses()).Data.(map[string]string) diff --git a/api/context.go b/api/context.go index 524ccf4029..257f431743 100644 --- a/api/context.go +++ b/api/context.go @@ -57,7 +57,7 @@ func AppHandlerIndependent(h func(*Context, http.ResponseWriter, *http.Request)) } func ApiUserRequired(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler { - return &handler{h, true, false, true, true, false, false} + return &handler{h, true, false, true, false, false, false} } func ApiUserRequiredActivity(h func(*Context, http.ResponseWriter, *http.Request), isUserActivity bool) http.Handler { @@ -85,7 +85,7 @@ func ApiAppHandlerTrustRequester(h func(*Context, http.ResponseWriter, *http.Req } func ApiUserRequiredTrustRequester(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler { - return &handler{h, true, false, true, true, false, true} + return &handler{h, true, false, true, false, false, true} } func ApiAppHandlerTrustRequesterIndependent(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler { @@ -220,7 +220,7 @@ func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { c.LogError(c.Err) c.Err.Where = r.URL.Path - // Block out detailed error whenn not in developer mode + // Block out detailed error when not in developer mode if !*utils.Cfg.ServiceSettings.EnableDeveloper { c.Err.DetailedError = "" } diff --git a/api/post.go b/api/post.go index 498f5b3638..5ae5f60db3 100644 --- a/api/post.go +++ b/api/post.go @@ -35,18 +35,18 @@ const ( func InitPost() { l4g.Debug(utils.T("api.post.init.debug")) - BaseRoutes.NeedTeam.Handle("/posts/search", ApiUserRequired(searchPosts)).Methods("POST") - BaseRoutes.NeedTeam.Handle("/posts/flagged/{offset:[0-9]+}/{limit:[0-9]+}", ApiUserRequiredActivity(getFlaggedPosts, false)).Methods("GET") + BaseRoutes.NeedTeam.Handle("/posts/search", ApiUserRequiredActivity(searchPosts, true)).Methods("POST") + BaseRoutes.NeedTeam.Handle("/posts/flagged/{offset:[0-9]+}/{limit:[0-9]+}", ApiUserRequired(getFlaggedPosts)).Methods("GET") BaseRoutes.NeedTeam.Handle("/posts/{post_id}", ApiUserRequired(getPostById)).Methods("GET") BaseRoutes.NeedTeam.Handle("/pltmp/{post_id}", ApiUserRequired(getPermalinkTmp)).Methods("GET") - BaseRoutes.Posts.Handle("/create", ApiUserRequired(createPost)).Methods("POST") - BaseRoutes.Posts.Handle("/update", ApiUserRequired(updatePost)).Methods("POST") - BaseRoutes.Posts.Handle("/page/{offset:[0-9]+}/{limit:[0-9]+}", ApiUserRequiredActivity(getPosts, false)).Methods("GET") - BaseRoutes.Posts.Handle("/since/{time:[0-9]+}", ApiUserRequiredActivity(getPostsSince, false)).Methods("GET") + BaseRoutes.Posts.Handle("/create", ApiUserRequiredActivity(createPost, true)).Methods("POST") + BaseRoutes.Posts.Handle("/update", ApiUserRequiredActivity(updatePost, true)).Methods("POST") + BaseRoutes.Posts.Handle("/page/{offset:[0-9]+}/{limit:[0-9]+}", ApiUserRequired(getPosts)).Methods("GET") + BaseRoutes.Posts.Handle("/since/{time:[0-9]+}", ApiUserRequired(getPostsSince)).Methods("GET") BaseRoutes.NeedPost.Handle("/get", ApiUserRequired(getPost)).Methods("GET") - BaseRoutes.NeedPost.Handle("/delete", ApiUserRequired(deletePost)).Methods("POST") + BaseRoutes.NeedPost.Handle("/delete", ApiUserRequiredActivity(deletePost, true)).Methods("POST") BaseRoutes.NeedPost.Handle("/before/{offset:[0-9]+}/{num_posts:[0-9]+}", ApiUserRequired(getPostsBefore)).Methods("GET") BaseRoutes.NeedPost.Handle("/after/{offset:[0-9]+}/{num_posts:[0-9]+}", ApiUserRequired(getPostsAfter)).Methods("GET") BaseRoutes.NeedPost.Handle("/get_file_infos", ApiUserRequired(getFileInfosForPost)).Methods("GET") @@ -154,7 +154,7 @@ func CreatePost(c *Context, post *model.Post, triggerWebhooks bool) (*model.Post } } - go handlePostEvents(c, rpost, triggerWebhooks) + handlePostEvents(c, rpost, triggerWebhooks) return rpost, nil } @@ -250,7 +250,7 @@ func handlePostEvents(c *Context, post *model.Post, triggerWebhooks bool) { channel = result.Data.(*model.Channel) } - go sendNotifications(c, post, team, channel) + sendNotifications(c, post, team, channel) var user *model.User if result := <-uchan; result.Err != nil { @@ -441,40 +441,31 @@ func handleWebhookEvents(c *Context, post *model.Post, team *model.Team, channel } } -// Given a map of user IDs to profiles and a map of user IDs of channel members, returns a list of mention -// keywords for all users on the team. Users that are members of the channel will have all their mention -// keywords returned while users that aren't in the channel will only have their @mentions returned. -func getMentionKeywords(profiles map[string]*model.User, members map[string]string) map[string][]string { +// Given a map of user IDs to profiles, returns a list of mention +// keywords for all users in the channel. +func getMentionKeywordsInChannel(profiles map[string]*model.User) map[string][]string { keywords := make(map[string][]string) for id, profile := range profiles { - _, inChannel := members[id] - - if inChannel { - if len(profile.NotifyProps["mention_keys"]) > 0 { - // Add all the user's mention keys - splitKeys := strings.Split(profile.NotifyProps["mention_keys"], ",") - for _, k := range splitKeys { - // note that these are made lower case so that we can do a case insensitive check for them - key := strings.ToLower(k) - keywords[key] = append(keywords[key], id) - } + if len(profile.NotifyProps["mention_keys"]) > 0 { + // Add all the user's mention keys + splitKeys := strings.Split(profile.NotifyProps["mention_keys"], ",") + for _, k := range splitKeys { + // note that these are made lower case so that we can do a case insensitive check for them + key := strings.ToLower(k) + keywords[key] = append(keywords[key], id) } + } - // If turned on, add the user's case sensitive first name - if profile.NotifyProps["first_name"] == "true" { - keywords[profile.FirstName] = append(keywords[profile.FirstName], profile.Id) - } + // If turned on, add the user's case sensitive first name + if profile.NotifyProps["first_name"] == "true" { + keywords[profile.FirstName] = append(keywords[profile.FirstName], profile.Id) + } - // Add @channel and @all to keywords if user has them turned on - if profile.NotifyProps["channel"] == "true" { - keywords["@channel"] = append(keywords["@channel"], profile.Id) - keywords["@all"] = append(keywords["@all"], profile.Id) - } - } else { - // user isn't in channel, so just look for @mentions - key := "@" + strings.ToLower(profile.Username) - keywords[key] = append(keywords[key], id) + // Add @channel and @all to keywords if user has them turned on + if profile.NotifyProps["channel"] == "true" { + keywords["@channel"] = append(keywords["@channel"], profile.Id) + keywords["@all"] = append(keywords["@all"], profile.Id) } } @@ -482,9 +473,11 @@ func getMentionKeywords(profiles map[string]*model.User, members map[string]stri } // Given a message and a map mapping mention keywords to the users who use them, returns a map of mentioned -// users and whether or not @here was mentioned. -func getExplicitMentions(message string, keywords map[string][]string) (map[string]bool, bool) { +// users and a slice of potencial mention users not in the channel and whether or not @here was mentioned. +func getExplicitMentions(message string, keywords map[string][]string) (map[string]bool, []string, bool) { mentioned := make(map[string]bool) + potentialOthersMentioned := make([]string, 0) + systemMentions := map[string]bool{"@here": true, "@channel": true, "@all": true} hereMentioned := false addMentionedUsers := func(ids []string) { @@ -510,6 +503,9 @@ func getExplicitMentions(message string, keywords map[string][]string) (map[stri if ids, match := keywords[word]; match { addMentionedUsers(ids) isMention = true + } else if _, ok := systemMentions[word]; !ok && strings.HasPrefix(word, "@") { + potentialOthersMentioned = append(potentialOthersMentioned, word[1:]) + continue } if !isMention { @@ -532,19 +528,19 @@ func getExplicitMentions(message string, keywords map[string][]string) (map[stri // Case-sensitive check for first name if ids, match := keywords[splitWord]; match { addMentionedUsers(ids) + } else if _, ok := systemMentions[word]; !ok && strings.HasPrefix(word, "@") { + username := word[1:len(splitWord)] + potentialOthersMentioned = append(potentialOthersMentioned, username) } } } } - return mentioned, hereMentioned + return mentioned, potentialOthersMentioned, hereMentioned } func sendNotifications(c *Context, post *model.Post, team *model.Team, channel *model.Channel) { - // get profiles for all users we could be mentioning - pchan := Srv.Store.User().GetProfiles(c.TeamId) - dpchan := Srv.Store.User().GetDirectProfiles(c.Session.UserId) - mchan := Srv.Store.Channel().GetMembers(post.ChannelId) + pchan := Srv.Store.User().GetProfilesInChannel(channel.Id, -1, -1, true) fchan := Srv.Store.FileInfo().GetForPost(post.Id) var profileMap map[string]*model.User @@ -555,30 +551,11 @@ func sendNotifications(c *Context, post *model.Post, team *model.Team, channel * profileMap = result.Data.(map[string]*model.User) } - if result := <-dpchan; result.Err != nil { - l4g.Error(utils.T("api.post.handle_post_events_and_forget.profiles.error"), c.TeamId, result.Err) - return - } else { - dps := result.Data.(map[string]*model.User) - for k, v := range dps { - profileMap[k] = v - } - } - + // If the user who made the post is mention don't send a notification if _, ok := profileMap[post.UserId]; !ok { l4g.Error(utils.T("api.post.send_notifications_and_forget.user_id.error"), post.UserId) return } - // using a map as a pseudo-set since we're checking for containment a lot - members := make(map[string]string) - if result := <-mchan; result.Err != nil { - l4g.Error(utils.T("api.post.handle_post_events_and_forget.members.error"), post.ChannelId, result.Err) - return - } else { - for _, member := range result.Data.([]model.ChannelMember) { - members[member.UserId] = member.UserId - } - } mentionedUserIds := make(map[string]bool) allActivityPushUserIds := []string{} @@ -595,11 +572,11 @@ func sendNotifications(c *Context, post *model.Post, team *model.Team, channel * mentionedUserIds[otherUserId] = true } else { - keywords := getMentionKeywords(profileMap, members) + keywords := getMentionKeywordsInChannel(profileMap) - // get users that are explicitly mentioned var mentioned map[string]bool - mentioned, hereNotification = getExplicitMentions(post.Message, keywords) + var potentialOtherMentions []string + mentioned, potentialOtherMentions, hereNotification = getExplicitMentions(post.Message, keywords) // get users that have comment thread mentions enabled if len(post.RootId) > 0 { @@ -623,25 +600,15 @@ func sendNotifications(c *Context, post *model.Post, team *model.Team, channel * delete(mentioned, post.UserId) } - outOfChannelMentions := make(map[string]bool) - for id := range mentioned { - if _, inChannel := members[id]; inChannel { - mentionedUserIds[id] = true - } else { - outOfChannelMentions[id] = true + if len(potentialOtherMentions) > 0 { + if result := <-Srv.Store.User().GetProfilesByUsernames(potentialOtherMentions, team.Id); result.Err == nil { + outOfChannelMentions := result.Data.(map[string]*model.User) + go sendOutOfChannelMentions(c, post, outOfChannelMentions) } } - go sendOutOfChannelMentions(c, post, profileMap, outOfChannelMentions) - // find which users in the channel are set up to always receive mobile notifications - for id := range members { - profile := profileMap[id] - if profile == nil { - l4g.Warn(utils.T("api.post.notification.member_profile.warn"), id) - continue - } - + for _, profile := range profileMap { if profile.NotifyProps["push"] == model.USER_NOTIFY_ALL && (post.UserId != profile.Id || post.Props["from_webhook"] == "true") && !post.IsSystemMessage() { @@ -699,10 +666,9 @@ func sendNotifications(c *Context, post *model.Post, team *model.Team, channel * } _, profileFound := profileMap[status.UserId] - _, isChannelMember := members[status.UserId] _, alreadyMentioned := mentionedUserIds[status.UserId] - if status.Status == model.STATUS_ONLINE && profileFound && isChannelMember && !alreadyMentioned { + if status.Status == model.STATUS_ONLINE && profileFound && !alreadyMentioned { mentionedUsersList = append(mentionedUsersList, status.UserId) updateMentionChans = append(updateMentionChans, Srv.Store.Channel().IncrementMentionCount(post.ChannelId, status.UserId)) } @@ -787,7 +753,8 @@ func sendNotifications(c *Context, post *model.Post, team *model.Team, channel * message.Add("mentions", model.ArrayToJson(mentionedUsersList)) } - go Publish(message) + Publish(message) + return } func sendNotificationEmail(c *Context, post *model.Post, user *model.User, channel *model.Channel, team *model.Team, senderName string, sender *model.User) { @@ -1045,14 +1012,14 @@ func getMobileAppSession(userId string) *model.Session { return nil } -func sendOutOfChannelMentions(c *Context, post *model.Post, profiles map[string]*model.User, outOfChannelMentions map[string]bool) { - if len(outOfChannelMentions) == 0 { +func sendOutOfChannelMentions(c *Context, post *model.Post, profiles map[string]*model.User) { + if len(profiles) == 0 { return } var usernames []string - for id := range outOfChannelMentions { - usernames = append(usernames, profiles[id].Username) + for _, user := range profiles { + usernames = append(usernames, user.Username) } sort.Strings(usernames) diff --git a/api/post_test.go b/api/post_test.go index bdc5278e4c..3c917aec36 100644 --- a/api/post_test.go +++ b/api/post_test.go @@ -883,10 +883,9 @@ func TestGetMentionKeywords(t *testing.T) { } profiles := map[string]*model.User{user1.Id: user1} - members := map[string]string{user1.Id: user1.Id} - mentions := getMentionKeywords(profiles, members) + mentions := getMentionKeywordsInChannel(profiles) if len(mentions) != 3 { - t.Fatal("should've returned two mention keywords") + t.Fatal("should've returned three mention keywords") } else if ids, ok := mentions["user"]; !ok || ids[0] != user1.Id { t.Fatal("should've returned mention key of user") } else if ids, ok := mentions["@user"]; !ok || ids[0] != user1.Id { @@ -906,8 +905,7 @@ func TestGetMentionKeywords(t *testing.T) { } profiles = map[string]*model.User{user2.Id: user2} - members = map[string]string{user2.Id: user2.Id} - mentions = getMentionKeywords(profiles, members) + mentions = getMentionKeywordsInChannel(profiles) if len(mentions) != 1 { t.Fatal("should've returned one mention keyword") } else if ids, ok := mentions["First"]; !ok || ids[0] != user2.Id { @@ -925,8 +923,7 @@ func TestGetMentionKeywords(t *testing.T) { } profiles = map[string]*model.User{user3.Id: user3} - members = map[string]string{user3.Id: user3.Id} - mentions = getMentionKeywords(profiles, members) + mentions = getMentionKeywordsInChannel(profiles) if len(mentions) != 2 { t.Fatal("should've returned two mention keywords") } else if ids, ok := mentions["@channel"]; !ok || ids[0] != user3.Id { @@ -948,8 +945,7 @@ func TestGetMentionKeywords(t *testing.T) { } profiles = map[string]*model.User{user4.Id: user4} - members = map[string]string{user4.Id: user4.Id} - mentions = getMentionKeywords(profiles, members) + mentions = getMentionKeywordsInChannel(profiles) if len(mentions) != 6 { t.Fatal("should've returned six mention keywords") } else if ids, ok := mentions["user"]; !ok || ids[0] != user4.Id { @@ -973,13 +969,7 @@ func TestGetMentionKeywords(t *testing.T) { user3.Id: user3, user4.Id: user4, } - members = map[string]string{ - user1.Id: user1.Id, - user2.Id: user2.Id, - user3.Id: user3.Id, - user4.Id: user4.Id, - } - mentions = getMentionKeywords(profiles, members) + mentions = getMentionKeywordsInChannel(profiles) if len(mentions) != 6 { t.Fatal("should've returned six mention keywords") } else if ids, ok := mentions["user"]; !ok || len(ids) != 2 || (ids[0] != user1.Id && ids[1] != user1.Id) || (ids[0] != user4.Id && ids[1] != user4.Id) { @@ -995,16 +985,6 @@ func TestGetMentionKeywords(t *testing.T) { } else if ids, ok := mentions["@all"]; !ok || len(ids) != 2 || (ids[0] != user3.Id && ids[1] != user3.Id) || (ids[0] != user4.Id && ids[1] != user4.Id) { t.Fatal("should've mentioned user3 and user4 with @all") } - - // a user that's not in the channel - profiles = map[string]*model.User{user4.Id: user4} - members = map[string]string{} - mentions = getMentionKeywords(profiles, members) - if len(mentions) != 1 { - t.Fatal("should've returned one mention keyword") - } else if ids, ok := mentions["@user"]; !ok || len(ids) != 1 || ids[0] != user4.Id { - t.Fatal("should've returned mention key of @user") - } } func TestGetExplicitMentionsAtHere(t *testing.T) { @@ -1051,7 +1031,7 @@ func TestGetExplicitMentionsAtHere(t *testing.T) { } for message, shouldMention := range cases { - if _, hereMentioned := getExplicitMentions(message, nil); hereMentioned && !shouldMention { + if _, _, hereMentioned := getExplicitMentions(message, nil); hereMentioned && !shouldMention { t.Fatalf("shouldn't have mentioned @here with \"%v\"", message) } else if !hereMentioned && shouldMention { t.Fatalf("should've have mentioned @here with \"%v\"", message) @@ -1060,10 +1040,12 @@ func TestGetExplicitMentionsAtHere(t *testing.T) { // mentioning @here and someone id := model.NewId() - if mentions, hereMentioned := getExplicitMentions("@here @user", map[string][]string{"@user": {id}}); !hereMentioned { + if mentions, potential, hereMentioned := getExplicitMentions("@here @user @potential", map[string][]string{"@user": {id}}); !hereMentioned { t.Fatal("should've mentioned @here with \"@here @user\"") } else if len(mentions) != 1 || !mentions[id] { t.Fatal("should've mentioned @user with \"@here @user\"") + } else if len(potential) > 1 { + t.Fatal("should've potential mentions for @potential") } } @@ -1074,69 +1056,76 @@ func TestGetExplicitMentions(t *testing.T) { // not mentioning anybody message := "this is a message" keywords := map[string][]string{} - if mentions, _ := getExplicitMentions(message, keywords); len(mentions) != 0 { - t.Fatal("shouldn't have mentioned anybody") + if mentions, potential, _ := getExplicitMentions(message, keywords); len(mentions) != 0 || len(potential) != 0 { + t.Fatal("shouldn't have mentioned anybody or have any potencial mentions") } // mentioning a user that doesn't exist message = "this is a message for @user" - if mentions, _ := getExplicitMentions(message, keywords); len(mentions) != 0 { + if mentions, _, _ := getExplicitMentions(message, keywords); len(mentions) != 0 { t.Fatal("shouldn't have mentioned user that doesn't exist") } // mentioning one person keywords = map[string][]string{"@user": {id1}} - if mentions, _ := getExplicitMentions(message, keywords); len(mentions) != 1 || !mentions[id1] { + if mentions, _, _ := getExplicitMentions(message, keywords); len(mentions) != 1 || !mentions[id1] { t.Fatal("should've mentioned @user") } // mentioning one person without an @mention message = "this is a message for @user" keywords = map[string][]string{"this": {id1}} - if mentions, _ := getExplicitMentions(message, keywords); len(mentions) != 1 || !mentions[id1] { + if mentions, _, _ := getExplicitMentions(message, keywords); len(mentions) != 1 || !mentions[id1] { t.Fatal("should've mentioned this") } // mentioning multiple people with one word message = "this is a message for @user" keywords = map[string][]string{"@user": {id1, id2}} - if mentions, _ := getExplicitMentions(message, keywords); len(mentions) != 2 || !mentions[id1] || !mentions[id2] { + if mentions, _, _ := getExplicitMentions(message, keywords); len(mentions) != 2 || !mentions[id1] || !mentions[id2] { t.Fatal("should've mentioned two users with @user") } // mentioning only one of multiple people keywords = map[string][]string{"@user": {id1}, "@mention": {id2}} - if mentions, _ := getExplicitMentions(message, keywords); len(mentions) != 1 || !mentions[id1] || mentions[id2] { + if mentions, _, _ := getExplicitMentions(message, keywords); len(mentions) != 1 || !mentions[id1] || mentions[id2] { t.Fatal("should've mentioned @user and not @mention") } // mentioning multiple people with multiple words message = "this is an @mention for @user" keywords = map[string][]string{"@user": {id1}, "@mention": {id2}} - if mentions, _ := getExplicitMentions(message, keywords); len(mentions) != 2 || !mentions[id1] || !mentions[id2] { + if mentions, _, _ := getExplicitMentions(message, keywords); len(mentions) != 2 || !mentions[id1] || !mentions[id2] { t.Fatal("should've mentioned two users with @user and @mention") } // mentioning @channel (not a special case, but it's good to double check) message = "this is an message for @channel" keywords = map[string][]string{"@channel": {id1, id2}} - if mentions, _ := getExplicitMentions(message, keywords); len(mentions) != 2 || !mentions[id1] || !mentions[id2] { + if mentions, _, _ := getExplicitMentions(message, keywords); len(mentions) != 2 || !mentions[id1] || !mentions[id2] { t.Fatal("should've mentioned two users with @channel") } // mentioning @all (not a special case, but it's good to double check) message = "this is an message for @all" keywords = map[string][]string{"@all": {id1, id2}} - if mentions, _ := getExplicitMentions(message, keywords); len(mentions) != 2 || !mentions[id1] || !mentions[id2] { + if mentions, _, _ := getExplicitMentions(message, keywords); len(mentions) != 2 || !mentions[id1] || !mentions[id2] { t.Fatal("should've mentioned two users with @all") } // mentioning user.period without mentioning user (PLT-3222) message = "user.period doesn't complicate things at all by including periods in their username" keywords = map[string][]string{"user.period": {id1}, "user": {id2}} - if mentions, _ := getExplicitMentions(message, keywords); len(mentions) != 1 || !mentions[id1] || mentions[id2] { + if mentions, _, _ := getExplicitMentions(message, keywords); len(mentions) != 1 || !mentions[id1] || mentions[id2] { t.Fatal("should've mentioned user.period and not user") } + + // mentioning a potential out of channel user + message = "this is an message for @potential and @user" + keywords = map[string][]string{"@user": {id1}} + if mentions, potential, _ := getExplicitMentions(message, keywords); len(mentions) != 1 || !mentions[id1] || len(potential) != 1 { + t.Fatal("should've mentioned user and have a potential not in channel") + } } func TestGetFlaggedPosts(t *testing.T) { diff --git a/api/server.go b/api/server.go index a7b9716c46..fee74e3733 100644 --- a/api/server.go +++ b/api/server.go @@ -7,6 +7,7 @@ import ( "crypto/tls" "net" "net/http" + "net/http/pprof" "strings" "time" @@ -36,7 +37,20 @@ const TIME_TO_WAIT_FOR_CONNECTIONS_TO_CLOSE_ON_SERVER_SHUTDOWN = time.Second var Srv *Server -func NewServer() { +func AttachProfiler(router *mux.Router) { + router.HandleFunc("/debug/pprof/", pprof.Index) + router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) + router.HandleFunc("/debug/pprof/profile", pprof.Profile) + router.HandleFunc("/debug/pprof/symbol", pprof.Symbol) + + // Manually add support for paths linked to by index page at /debug/pprof/ + router.Handle("/debug/pprof/goroutine", pprof.Handler("goroutine")) + router.Handle("/debug/pprof/heap", pprof.Handler("heap")) + router.Handle("/debug/pprof/threadcreate", pprof.Handler("threadcreate")) + router.Handle("/debug/pprof/block", pprof.Handler("block")) +} + +func NewServer(enableProfiler bool) { l4g.Info(utils.T("api.server.new_server.init.info")) @@ -44,6 +58,10 @@ func NewServer() { Srv.Store = store.NewSqlStore() Srv.Router = mux.NewRouter() + if enableProfiler { + AttachProfiler(Srv.Router) + l4g.Info("Enabled HTTP Profiler") + } Srv.Router.NotFoundHandler = http.HandlerFunc(Handle404) } @@ -177,7 +195,7 @@ func StopServer() { Srv.GracefulServer.Stop(TIME_TO_WAIT_FOR_CONNECTIONS_TO_CLOSE_ON_SERVER_SHUTDOWN) Srv.Store.Close() - hub.Stop() + HubStop() l4g.Info(utils.T("api.server.stop_server.stopped.info")) } diff --git a/api/status.go b/api/status.go index a1a5ac496c..897102a4be 100644 --- a/api/status.go +++ b/api/status.go @@ -31,9 +31,11 @@ func AddStatusCache(status *model.Status) { func InitStatus() { l4g.Debug(utils.T("api.status.init.debug")) - BaseRoutes.Users.Handle("/status", ApiUserRequiredActivity(getStatusesHttp, false)).Methods("GET") - BaseRoutes.Users.Handle("/status/set_active_channel", ApiUserRequiredActivity(setActiveChannel, false)).Methods("POST") + BaseRoutes.Users.Handle("/status", ApiUserRequired(getStatusesHttp)).Methods("GET") + BaseRoutes.Users.Handle("/status/ids", ApiUserRequired(getStatusesByIdsHttp)).Methods("POST") + BaseRoutes.Users.Handle("/status/set_active_channel", ApiUserRequired(setActiveChannel)).Methods("POST") BaseRoutes.WebSocket.Handle("get_statuses", ApiWebSocketHandler(getStatusesWebSocket)) + BaseRoutes.WebSocket.Handle("get_statuses_by_ids", ApiWebSocketHandler(getStatusesByIdsWebSocket)) } func getStatusesHttp(c *Context, w http.ResponseWriter, r *http.Request) { @@ -55,6 +57,7 @@ func getStatusesWebSocket(req *model.WebSocketRequest) (map[string]interface{}, return statusMap, nil } +// Only returns 300 statuses max func GetAllStatuses() (map[string]interface{}, *model.AppError) { if result := <-Srv.Store.Status().GetOnlineAway(); result.Err != nil { return nil, result.Err @@ -70,11 +73,82 @@ func GetAllStatuses() (map[string]interface{}, *model.AppError) { } } +func getStatusesByIdsHttp(c *Context, w http.ResponseWriter, r *http.Request) { + userIds := model.ArrayFromJson(r.Body) + + if len(userIds) == 0 { + c.SetInvalidParam("getStatusesByIdsHttp", "user_ids") + return + } + + statusMap, err := GetStatusesByIds(userIds) + if err != nil { + c.Err = err + return + } + + w.Write([]byte(model.StringInterfaceToJson(statusMap))) +} + +func getStatusesByIdsWebSocket(req *model.WebSocketRequest) (map[string]interface{}, *model.AppError) { + var userIds []string + if userIds = model.ArrayFromInterface(req.Data["user_ids"]); len(userIds) == 0 { + l4g.Error(model.StringInterfaceToJson(req.Data)) + return nil, NewInvalidWebSocketParamError(req.Action, "user_ids") + } + + statusMap, err := GetStatusesByIds(userIds) + if err != nil { + return nil, err + } + + return statusMap, nil +} + +func GetStatusesByIds(userIds []string) (map[string]interface{}, *model.AppError) { + statusMap := map[string]interface{}{} + + missingUserIds := []string{} + for _, userId := range userIds { + if result, ok := statusCache.Get(userId); ok { + statusMap[userId] = result.(*model.Status).Status + } else { + missingUserIds = append(missingUserIds, userId) + } + } + + if len(missingUserIds) > 0 { + if result := <-Srv.Store.Status().GetByIds(missingUserIds); result.Err != nil { + return nil, result.Err + } else { + statuses := result.Data.([]*model.Status) + + for _, s := range statuses { + AddStatusCache(s) + statusMap[s.UserId] = s.Status + } + } + } + + // For the case where the user does not have a row in the Status table and cache + for _, userId := range missingUserIds { + if _, ok := statusMap[userId]; !ok { + statusMap[userId] = model.STATUS_OFFLINE + } + } + + return statusMap, nil +} + func SetStatusOnline(userId string, sessionId string, manual bool) { broadcast := false + var oldStatus string = model.STATUS_OFFLINE + var oldTime int64 = 0 + var oldManual bool = false var status *model.Status var err *model.AppError + if status, err = GetStatus(userId); err != nil { status = &model.Status{userId, model.STATUS_ONLINE, false, model.GetMillis(), ""} broadcast = true @@ -82,35 +156,45 @@ func SetStatusOnline(userId string, sessionId string, manual bool) { if status.Manual && !manual { return // manually set status always overrides non-manual one } + if status.Status != model.STATUS_ONLINE { broadcast = true } + + oldStatus = status.Status + oldTime = status.LastActivityAt + oldManual = status.Manual + status.Status = model.STATUS_ONLINE - status.Manual = false // for "online" there's no manually or auto set + status.Manual = false // for "online" there's no manual setting status.LastActivityAt = model.GetMillis() } AddStatusCache(status) - achan := Srv.Store.Session().UpdateLastActivityAt(sessionId, model.GetMillis()) + // Only update the database if the status has changed, the status has been manually set, + // or enough time has passed since the previous action + if status.Status != oldStatus || status.Manual != oldManual || status.LastActivityAt-oldTime > model.STATUS_MIN_UPDATE_TIME { + achan := Srv.Store.Session().UpdateLastActivityAt(sessionId, status.LastActivityAt) - var schan store.StoreChannel - if broadcast { - schan = Srv.Store.Status().SaveOrUpdate(status) - } else { - schan = Srv.Store.Status().UpdateLastActivityAt(status.UserId, status.LastActivityAt) - } + var schan store.StoreChannel + if broadcast { + schan = Srv.Store.Status().SaveOrUpdate(status) + } else { + schan = Srv.Store.Status().UpdateLastActivityAt(status.UserId, status.LastActivityAt) + } - if result := <-achan; result.Err != nil { - l4g.Error(utils.T("api.status.last_activity.error"), userId, sessionId, result.Err) - } + if result := <-achan; result.Err != nil { + l4g.Error(utils.T("api.status.last_activity.error"), userId, sessionId, result.Err) + } - if result := <-schan; result.Err != nil { - l4g.Error(utils.T("api.status.save_status.error"), userId, result.Err) + if result := <-schan; result.Err != nil { + l4g.Error(utils.T("api.status.save_status.error"), userId, result.Err) + } } if broadcast { - event := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_STATUS_CHANGE, "", "", "", nil) + event := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_STATUS_CHANGE, "", "", status.UserId, nil) event.Add("status", model.STATUS_ONLINE) event.Add("user_id", status.UserId) go Publish(event) @@ -131,7 +215,7 @@ func SetStatusOffline(userId string, manual bool) { l4g.Error(utils.T("api.status.save_status.error"), userId, result.Err) } - event := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_STATUS_CHANGE, "", "", "", nil) + event := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_STATUS_CHANGE, "", "", status.UserId, nil) event.Add("status", model.STATUS_OFFLINE) event.Add("user_id", status.UserId) go Publish(event) @@ -168,15 +252,18 @@ func SetStatusAwayIfNeeded(userId string, manual bool) { l4g.Error(utils.T("api.status.save_status.error"), userId, result.Err) } - event := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_STATUS_CHANGE, "", "", "", nil) + event := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_STATUS_CHANGE, "", "", status.UserId, nil) event.Add("status", model.STATUS_AWAY) event.Add("user_id", status.UserId) go Publish(event) } func GetStatus(userId string) (*model.Status, *model.AppError) { - if status, ok := statusCache.Get(userId); ok { - return status.(*model.Status), nil + if result, ok := statusCache.Get(userId); ok { + status := result.(*model.Status) + statusCopy := &model.Status{} + *statusCopy = *status + return statusCopy, nil } if result := <-Srv.Store.Status().Get(userId); result.Err != nil { @@ -232,6 +319,10 @@ func SetActiveChannel(userId string, channelId string) *model.AppError { status = &model.Status{userId, model.STATUS_ONLINE, false, model.GetMillis(), channelId} } else { status.ActiveChannel = channelId + if !status.Manual { + status.Status = model.STATUS_ONLINE + } + status.LastActivityAt = model.GetMillis() } AddStatusCache(status) diff --git a/api/status_test.go b/api/status_test.go index f15c239dbd..ffc946817d 100644 --- a/api/status_test.go +++ b/api/status_test.go @@ -4,13 +4,12 @@ package api import ( - "strings" - "testing" - "time" - "github.com/mattermost/platform/model" "github.com/mattermost/platform/store" "github.com/mattermost/platform/utils" + "strings" + "testing" + "time" ) func TestStatuses(t *testing.T) { @@ -59,7 +58,7 @@ func TestStatuses(t *testing.T) { t.Fatal(err2) } - time.Sleep(300 * time.Millisecond) + time.Sleep(500 * time.Millisecond) WebSocketClient.GetStatuses() if resp := <-WebSocketClient.ResponseChannel; resp.Error != nil { @@ -76,6 +75,7 @@ func TestStatuses(t *testing.T) { } if status, ok := resp.Data[th.BasicUser2.Id]; !ok { + t.Log(len(resp.Data)) t.Fatal("should have had user status") } else if status != model.STATUS_ONLINE { t.Log(status) @@ -83,7 +83,55 @@ func TestStatuses(t *testing.T) { } } - SetStatusAwayIfNeeded(th.BasicUser2.Id, false) + WebSocketClient.GetStatusesByIds([]string{th.BasicUser2.Id}) + if resp := <-WebSocketClient.ResponseChannel; resp.Error != nil { + t.Fatal(resp.Error) + } else { + if resp.SeqReply != WebSocketClient.Sequence-1 { + t.Fatal("bad sequence number") + } + + for _, status := range resp.Data { + if status != model.STATUS_OFFLINE && status != model.STATUS_AWAY && status != model.STATUS_ONLINE { + t.Fatal("one of the statuses had an invalid value") + } + } + + if status, ok := resp.Data[th.BasicUser2.Id]; !ok { + t.Log(len(resp.Data)) + t.Fatal("should have had user status") + } else if status != model.STATUS_ONLINE { + t.Log(status) + t.Fatal("status should have been online") + } else if len(resp.Data) != 1 { + t.Fatal("only 1 status should be returned") + } + } + + WebSocketClient.GetStatusesByIds([]string{ruser2.Id, "junk"}) + if resp := <-WebSocketClient.ResponseChannel; resp.Error != nil { + t.Fatal(resp.Error) + } else { + if resp.SeqReply != WebSocketClient.Sequence-1 { + t.Fatal("bad sequence number") + } + + if len(resp.Data) != 2 { + t.Fatal("2 statuses should be returned") + } + } + + WebSocketClient.GetStatusesByIds([]string{}) + if resp := <-WebSocketClient.ResponseChannel; resp.Error == nil { + if resp.SeqReply != WebSocketClient.Sequence-1 { + t.Fatal("bad sequence number") + } + t.Fatal("should have errored - empty user ids") + } + + WebSocketClient2.Close() + + SetStatusAwayIfNeeded(th.BasicUser.Id, false) awayTimeout := *utils.Cfg.TeamSettings.UserStatusAwayTimeout defer func() { @@ -93,10 +141,9 @@ func TestStatuses(t *testing.T) { time.Sleep(1500 * time.Millisecond) - SetStatusAwayIfNeeded(th.BasicUser2.Id, false) - SetStatusAwayIfNeeded(th.BasicUser2.Id, false) + SetStatusAwayIfNeeded(th.BasicUser.Id, false) + SetStatusOnline(th.BasicUser.Id, "junk", false) - WebSocketClient2.Close() time.Sleep(300 * time.Millisecond) WebSocketClient.GetStatuses() @@ -115,20 +162,17 @@ func TestStatuses(t *testing.T) { stop := make(chan bool) onlineHit := false awayHit := false - offlineHit := false go func() { for { select { case resp := <-WebSocketClient.EventChannel: - if resp.Event == model.WEBSOCKET_EVENT_STATUS_CHANGE && resp.Data["user_id"].(string) == th.BasicUser2.Id { + if resp.Event == model.WEBSOCKET_EVENT_STATUS_CHANGE && resp.Data["user_id"].(string) == th.BasicUser.Id { status := resp.Data["status"].(string) if status == model.STATUS_ONLINE { onlineHit = true } else if status == model.STATUS_AWAY { awayHit = true - } else if status == model.STATUS_OFFLINE { - offlineHit = true } } case <-stop: @@ -147,11 +191,40 @@ func TestStatuses(t *testing.T) { if !awayHit { t.Fatal("didn't get away event") } - if !offlineHit { - t.Fatal("didn't get offline event") + + time.Sleep(500 * time.Millisecond) + + WebSocketClient.Close() +} + +func TestGetStatusesByIds(t *testing.T) { + th := Setup().InitBasic() + Client := th.BasicClient + + if result, err := Client.GetStatusesByIds([]string{th.BasicUser.Id}); err != nil { + t.Fatal(err) + } else { + statuses := result.Data.(map[string]string) + if len(statuses) != 1 { + t.Fatal("should only have 1 status") + } + } + + if result, err := Client.GetStatusesByIds([]string{th.BasicUser.Id, th.BasicUser2.Id, "junk"}); err != nil { + t.Fatal(err) + } else { + statuses := result.Data.(map[string]string) + if len(statuses) != 3 { + t.Fatal("should have 3 statuses") + } + } + + if _, err := Client.GetStatusesByIds([]string{}); err == nil { + t.Fatal("should have errored") } } +/* func TestSetActiveChannel(t *testing.T) { th := Setup().InitBasic() Client := th.BasicClient @@ -185,8 +258,9 @@ func TestSetActiveChannel(t *testing.T) { time.Sleep(500 * time.Millisecond) status, _ = GetStatus(th.BasicUser.Id) - // need to check if offline to catch race + need to check if offline to catch race if status.Status != model.STATUS_OFFLINE && status.ActiveChannel != th.BasicChannel.Id { t.Fatal("active channel should be set") } } +*/ diff --git a/api/team.go b/api/team.go index 2be7b85454..c57591d465 100644 --- a/api/team.go +++ b/api/team.go @@ -31,9 +31,12 @@ func InitTeam() { BaseRoutes.Teams.Handle("/all_team_listings", ApiUserRequired(GetAllTeamListings)).Methods("GET") BaseRoutes.Teams.Handle("/get_invite_info", ApiAppHandler(getInviteInfo)).Methods("POST") BaseRoutes.Teams.Handle("/find_team_by_name", ApiAppHandler(findTeamByName)).Methods("POST") - BaseRoutes.Teams.Handle("/members/{id:[A-Za-z0-9]+}", ApiUserRequired(getMembers)).Methods("GET") BaseRoutes.NeedTeam.Handle("/me", ApiUserRequired(getMyTeam)).Methods("GET") + BaseRoutes.NeedTeam.Handle("/stats", ApiUserRequired(getTeamStats)).Methods("GET") + BaseRoutes.NeedTeam.Handle("/members/{offset:[0-9]+}/{limit:[0-9]+}", ApiUserRequired(getTeamMembers)).Methods("GET") + BaseRoutes.NeedTeam.Handle("/members/ids", ApiUserRequired(getTeamMembersByIds)).Methods("POST") + BaseRoutes.NeedTeam.Handle("/members/{user_id:[A-Za-z0-9]+}", ApiUserRequired(getTeamMember)).Methods("GET") BaseRoutes.NeedTeam.Handle("/update", ApiUserRequired(updateTeam)).Methods("POST") BaseRoutes.NeedTeam.Handle("/update_member_roles", ApiUserRequired(updateMemberRoles)).Methods("POST") @@ -305,7 +308,9 @@ func JoinUserToTeam(team *model.Team, user *model.User) *model.AppError { InvalidateCacheForUser(user.Id) // This message goes to everyone, so the teamId, channelId and userId are irrelevant - go Publish(model.NewWebSocketEvent(model.WEBSOCKET_EVENT_NEW_USER, "", "", "", nil)) + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_NEW_USER, "", "", "", nil) + message.Add("user_id", user.Id) + go Publish(message) return nil } @@ -335,11 +340,10 @@ func LeaveTeam(team *model.Team, user *model.User) *model.AppError { for _, channel := range channelMembers.Channels { if channel.Type != model.CHANNEL_DIRECT { + Srv.Store.User().InvalidateProfilesInChannelCache(channel.Id) if result := <-Srv.Store.Channel().RemoveMember(channel.Id, user.Id); result.Err != nil { return result.Err } - - InvalidateCacheForChannel(channel.Id) } } @@ -889,6 +893,25 @@ func getMyTeam(c *Context, w http.ResponseWriter, r *http.Request) { } } +func getTeamStats(c *Context, w http.ResponseWriter, r *http.Request) { + if c.Session.GetTeamByTeamId(c.TeamId) == nil { + if !HasPermissionToContext(c, model.PERMISSION_MANAGE_SYSTEM) { + return + } + } + + if result := <-Srv.Store.Team().GetMemberCount(c.TeamId); result.Err != nil { + c.Err = result.Err + return + } else { + stats := &model.TeamStats{} + stats.MemberCount = result.Data.(int64) + stats.TeamId = c.TeamId + w.Write([]byte(stats.ToJson())) + return + } +} + func importTeam(c *Context, w http.ResponseWriter, r *http.Request) { if !HasPermissionToCurrentTeamContext(c, model.PERMISSION_IMPORT_TEAM) { c.Err = model.NewLocAppError("importTeam", "api.team.import_team.admin.app_error", nil, "userId="+c.Session.UserId) @@ -982,17 +1005,76 @@ func getInviteInfo(c *Context, w http.ResponseWriter, r *http.Request) { } } -func getMembers(c *Context, w http.ResponseWriter, r *http.Request) { +func getTeamMembers(c *Context, w http.ResponseWriter, r *http.Request) { params := mux.Vars(r) - id := params["id"] - if c.Session.GetTeamByTeamId(id) == nil { - if !HasPermissionToTeamContext(c, id, model.PERMISSION_MANAGE_SYSTEM) { + offset, err := strconv.Atoi(params["offset"]) + if err != nil { + c.SetInvalidParam("getTeamMembers", "offset") + return + } + + limit, err := strconv.Atoi(params["limit"]) + if err != nil { + c.SetInvalidParam("getTeamMembers", "limit") + return + } + + if c.Session.GetTeamByTeamId(c.TeamId) == nil { + if !HasPermissionToTeamContext(c, c.TeamId, model.PERMISSION_MANAGE_SYSTEM) { return } } - if result := <-Srv.Store.Team().GetMembers(id); result.Err != nil { + if result := <-Srv.Store.Team().GetMembers(c.TeamId, offset, limit); result.Err != nil { + c.Err = result.Err + return + } else { + members := result.Data.([]*model.TeamMember) + w.Write([]byte(model.TeamMembersToJson(members))) + return + } +} + +func getTeamMember(c *Context, w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + + userId := params["user_id"] + if len(userId) < 26 { + c.SetInvalidParam("getTeamMember", "user_id") + return + } + + if c.Session.GetTeamByTeamId(c.TeamId) == nil { + if !HasPermissionToTeamContext(c, c.TeamId, model.PERMISSION_MANAGE_SYSTEM) { + return + } + } + + if result := <-Srv.Store.Team().GetMember(c.TeamId, userId); result.Err != nil { + c.Err = result.Err + return + } else { + member := result.Data.(model.TeamMember) + w.Write([]byte(member.ToJson())) + return + } +} + +func getTeamMembersByIds(c *Context, w http.ResponseWriter, r *http.Request) { + userIds := model.ArrayFromJson(r.Body) + if len(userIds) == 0 { + c.SetInvalidParam("getTeamMembersByIds", "user_ids") + return + } + + if c.Session.GetTeamByTeamId(c.TeamId) == nil { + if !HasPermissionToTeamContext(c, c.TeamId, model.PERMISSION_MANAGE_SYSTEM) { + return + } + } + + if result := <-Srv.Store.Team().GetMembersByIds(c.TeamId, userIds); result.Err != nil { c.Err = result.Err return } else { diff --git a/api/team_test.go b/api/team_test.go index 1a66a826ff..a58710145d 100644 --- a/api/team_test.go +++ b/api/team_test.go @@ -560,14 +560,80 @@ func TestGetMyTeam(t *testing.T) { func TestGetTeamMembers(t *testing.T) { th := Setup().InitBasic() - if result, err := th.BasicClient.GetTeamMembers(th.BasicTeam.Id); err != nil { + if result, err := th.BasicClient.GetTeamMembers(th.BasicTeam.Id, 0, 100); err != nil { t.Fatal(err) } else { members := result.Data.([]*model.TeamMember) - if members == nil { + if len(members) == 0 { + t.Fatal("should have results") + } + } + + if _, err := th.BasicClient.GetTeamMembers("junk", 0, 100); err == nil { + t.Fatal("should have errored - bad team id") + } +} + +func TestGetTeamMember(t *testing.T) { + th := Setup().InitBasic() + + if result, err := th.BasicClient.GetTeamMember(th.BasicTeam.Id, th.BasicUser.Id); err != nil { + t.Fatal(err) + } else { + member := result.Data.(*model.TeamMember) + if member == nil { t.Fatal("should be valid") } } + + if _, err := th.BasicClient.GetTeamMember("junk", th.BasicUser.Id); err == nil { + t.Fatal("should have errored - bad team id") + } + + if _, err := th.BasicClient.GetTeamMember(th.BasicTeam.Id, ""); err == nil { + t.Fatal("should have errored - blank user id") + } + + if _, err := th.BasicClient.GetTeamMember(th.BasicTeam.Id, "junk"); err == nil { + t.Fatal("should have errored - bad user id") + } + + if _, err := th.BasicClient.GetTeamMember(th.BasicTeam.Id, "12345678901234567890123456"); err == nil { + t.Fatal("should have errored - bad user id") + } +} + +func TestGetTeamMembersByIds(t *testing.T) { + th := Setup().InitBasic() + + if result, err := th.BasicClient.GetTeamMembersByIds(th.BasicTeam.Id, []string{th.BasicUser.Id}); err != nil { + t.Fatal(err) + } else { + member := result.Data.([]*model.TeamMember)[0] + if member.UserId != th.BasicUser.Id { + t.Fatal("user id did not match") + } + if member.TeamId != th.BasicTeam.Id { + t.Fatal("team id did not match") + } + } + + if result, err := th.BasicClient.GetTeamMembersByIds(th.BasicTeam.Id, []string{th.BasicUser.Id, th.BasicUser2.Id, model.NewId()}); err != nil { + t.Fatal(err) + } else { + members := result.Data.([]*model.TeamMember) + if len(members) != 2 { + t.Fatal("length should have been 2") + } + } + + if _, err := th.BasicClient.GetTeamMembersByIds("junk", []string{th.BasicUser.Id}); err == nil { + t.Fatal("should have errored - bad team id") + } + + if _, err := th.BasicClient.GetTeamMembersByIds(th.BasicTeam.Id, []string{}); err == nil { + t.Fatal("should have errored - empty user ids") + } } func TestUpdateTeamMemberRoles(t *testing.T) { @@ -632,3 +698,42 @@ func TestUpdateTeamMemberRoles(t *testing.T) { t.Fatal("Should have worked, user is team admin") } } + +func TestGetTeamStats(t *testing.T) { + th := Setup().InitBasic().InitSystemAdmin() + Client := th.BasicClient + + if result, err := th.SystemAdminClient.GetTeamStats(th.BasicTeam.Id); err != nil { + t.Fatal(err) + } else { + if result.Data.(*model.TeamStats).MemberCount != 2 { + t.Fatal("wrong count") + } + } + + if result, err := th.SystemAdminClient.GetTeamStats("junk"); err != nil { + t.Fatal(err) + } else { + if result.Data.(*model.TeamStats).MemberCount != 0 { + t.Fatal("wrong count") + } + } + + if result, err := th.SystemAdminClient.GetTeamStats(th.BasicTeam.Id); err != nil { + t.Fatal(err) + } else { + if result.Data.(*model.TeamStats).MemberCount != 2 { + t.Fatal("wrong count") + } + } + + user := model.User{Email: "success+" + model.NewId() + "@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "passwd1"} + ruser, _ := Client.CreateUser(&user, "") + store.Must(Srv.Store.User().VerifyEmail(ruser.Data.(*model.User).Id)) + + Client.Login(user.Email, user.Password) + + if _, err := Client.GetTeamStats(th.BasicTeam.Id); err == nil { + t.Fatal("should have errored - not on team") + } +} diff --git a/api/user.go b/api/user.go index 5f7e3ad10d..12e57a33f6 100644 --- a/api/user.go +++ b/api/user.go @@ -53,9 +53,15 @@ func InitUser() { BaseRoutes.Users.Handle("/newimage", ApiUserRequired(uploadProfileImage)).Methods("POST") BaseRoutes.Users.Handle("/me", ApiUserRequired(getMe)).Methods("GET") BaseRoutes.Users.Handle("/initial_load", ApiAppHandler(getInitialLoad)).Methods("GET") - BaseRoutes.Users.Handle("/direct_profiles", ApiUserRequired(getDirectProfiles)).Methods("GET") - BaseRoutes.Users.Handle("/profiles/{id:[A-Za-z0-9]+}", ApiUserRequired(getProfiles)).Methods("GET") - BaseRoutes.Users.Handle("/profiles_for_dm_list/{id:[A-Za-z0-9]+}", ApiUserRequired(getProfilesForDirectMessageList)).Methods("GET") + BaseRoutes.Users.Handle("/{offset:[0-9]+}/{limit:[0-9]+}", ApiUserRequired(getProfiles)).Methods("GET") + BaseRoutes.NeedTeam.Handle("/users/{offset:[0-9]+}/{limit:[0-9]+}", ApiUserRequired(getProfilesInTeam)).Methods("GET") + BaseRoutes.NeedChannel.Handle("/users/{offset:[0-9]+}/{limit:[0-9]+}", ApiUserRequired(getProfilesInChannel)).Methods("GET") + BaseRoutes.NeedChannel.Handle("/users/not_in_channel/{offset:[0-9]+}/{limit:[0-9]+}", ApiUserRequired(getProfilesNotInChannel)).Methods("GET") + BaseRoutes.Users.Handle("/search", ApiUserRequired(searchUsers)).Methods("POST") + BaseRoutes.Users.Handle("/ids", ApiUserRequired(getProfilesByIds)).Methods("POST") + + BaseRoutes.NeedTeam.Handle("/users/autocomplete", ApiUserRequired(autocompleteUsersInTeam)).Methods("GET") + BaseRoutes.NeedChannel.Handle("/users/autocomplete", ApiUserRequired(autocompleteUsersInChannel)).Methods("GET") BaseRoutes.Users.Handle("/mfa", ApiAppHandler(checkMfa)).Methods("POST") BaseRoutes.Users.Handle("/generate_mfa_qr", ApiUserRequiredTrustRequester(generateMfaQrCode)).Methods("GET") @@ -270,7 +276,9 @@ func CreateUser(user *model.User) (*model.User, *model.AppError) { ruser.Sanitize(map[string]bool{}) // This message goes to everyone, so the teamId, channelId and userId are irrelevant - go Publish(model.NewWebSocketEvent(model.WEBSOCKET_EVENT_NEW_USER, "", "", "", nil)) + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_NEW_USER, "", "", "", nil) + message.Add("user_id", ruser.Id) + go Publish(message) return ruser, nil } @@ -379,7 +387,7 @@ func sendWelcomeEmail(c *Context, userId string, email string, siteURL string, v func addDirectChannels(teamId string, user *model.User) { var profiles map[string]*model.User - if result := <-Srv.Store.User().GetProfiles(teamId); result.Err != nil { + if result := <-Srv.Store.User().GetProfiles(teamId, 0, 100); result.Err != nil { l4g.Error(utils.T("api.user.add_direct_channels_and_forget.failed.error"), user.Id, teamId, result.Err.Error()) return } else { @@ -875,7 +883,6 @@ func getInitialLoad(c *Context, w http.ResponseWriter, r *http.Request) { uchan := Srv.Store.User().Get(c.Session.UserId) pchan := Srv.Store.Preference().GetAll(c.Session.UserId) tchan := Srv.Store.Team().GetTeamsByUserId(c.Session.UserId) - dpchan := Srv.Store.User().GetDirectProfiles(c.Session.UserId) il.TeamMembers = c.Session.TeamMembers @@ -904,19 +911,6 @@ func getInitialLoad(c *Context, w http.ResponseWriter, r *http.Request) { team.Sanitize() } } - - if dp := <-dpchan; dp.Err != nil { - c.Err = dp.Err - return - } else { - profiles := dp.Data.(map[string]*model.User) - - for k, p := range profiles { - profiles[k] = sanitizeProfile(c, p) - } - - il.DirectProfiles = profiles - } } if cchan != nil { @@ -960,25 +954,27 @@ func getUser(c *Context, w http.ResponseWriter, r *http.Request) { } } -func getProfilesForDirectMessageList(c *Context, w http.ResponseWriter, r *http.Request) { +func getProfiles(c *Context, w http.ResponseWriter, r *http.Request) { params := mux.Vars(r) - id := params["id"] - var pchan store.StoreChannel - - if *utils.Cfg.TeamSettings.RestrictDirectMessage == model.DIRECT_MESSAGE_TEAM { - if c.Session.GetTeamByTeamId(id) == nil { - if !HasPermissionToContext(c, model.PERMISSION_MANAGE_SYSTEM) { - return - } - } - - pchan = Srv.Store.User().GetProfiles(id) - } else { - pchan = Srv.Store.User().GetAllProfiles() + offset, err := strconv.Atoi(params["offset"]) + if err != nil { + c.SetInvalidParam("getProfiles", "offset") + return } - if result := <-pchan; result.Err != nil { + limit, err := strconv.Atoi(params["limit"]) + if err != nil { + c.SetInvalidParam("getProfiles", "limit") + return + } + + etag := (<-Srv.Store.User().GetEtagForAllProfiles()).Data.(string) + if HandleEtag(etag, w, r) { + return + } + + if result := <-Srv.Store.User().GetAllProfiles(offset, limit); result.Err != nil { c.Err = result.Err return } else { @@ -988,26 +984,39 @@ func getProfilesForDirectMessageList(c *Context, w http.ResponseWriter, r *http. profiles[k] = sanitizeProfile(c, p) } + w.Header().Set(model.HEADER_ETAG_SERVER, etag) w.Write([]byte(model.UserMapToJson(profiles))) } } -func getProfiles(c *Context, w http.ResponseWriter, r *http.Request) { +func getProfilesInTeam(c *Context, w http.ResponseWriter, r *http.Request) { params := mux.Vars(r) - id := params["id"] + teamId := params["team_id"] - if c.Session.GetTeamByTeamId(id) == nil { + if c.Session.GetTeamByTeamId(teamId) == nil { if !HasPermissionToContext(c, model.PERMISSION_MANAGE_SYSTEM) { return } } - etag := (<-Srv.Store.User().GetEtagForProfiles(id)).Data.(string) + offset, err := strconv.Atoi(params["offset"]) + if err != nil { + c.SetInvalidParam("getProfilesInTeam", "offset") + return + } + + limit, err := strconv.Atoi(params["limit"]) + if err != nil { + c.SetInvalidParam("getProfilesInTeam", "limit") + return + } + + etag := (<-Srv.Store.User().GetEtagForProfiles(teamId)).Data.(string) if HandleEtag(etag, w, r) { return } - if result := <-Srv.Store.User().GetProfiles(id); result.Err != nil { + if result := <-Srv.Store.User().GetProfiles(teamId, offset, limit); result.Err != nil { c.Err = result.Err return } else { @@ -1022,13 +1031,73 @@ func getProfiles(c *Context, w http.ResponseWriter, r *http.Request) { } } -func getDirectProfiles(c *Context, w http.ResponseWriter, r *http.Request) { - etag := (<-Srv.Store.User().GetEtagForDirectProfiles(c.Session.UserId)).Data.(string) - if HandleEtag(etag, w, r) { +func getProfilesInChannel(c *Context, w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + channelId := params["channel_id"] + + if c.Session.GetTeamByTeamId(c.TeamId) == nil { + if !HasPermissionToContext(c, model.PERMISSION_MANAGE_SYSTEM) { + return + } + } + + if !HasPermissionToChannelContext(c, channelId, model.PERMISSION_READ_CHANNEL) { return } - if result := <-Srv.Store.User().GetDirectProfiles(c.Session.UserId); result.Err != nil { + offset, err := strconv.Atoi(params["offset"]) + if err != nil { + c.SetInvalidParam("getProfiles", "offset") + return + } + + limit, err := strconv.Atoi(params["limit"]) + if err != nil { + c.SetInvalidParam("getProfiles", "limit") + return + } + + if result := <-Srv.Store.User().GetProfilesInChannel(channelId, offset, limit, false); result.Err != nil { + c.Err = result.Err + return + } else { + profiles := result.Data.(map[string]*model.User) + + for k, p := range profiles { + profiles[k] = sanitizeProfile(c, p) + } + + w.Write([]byte(model.UserMapToJson(profiles))) + } +} + +func getProfilesNotInChannel(c *Context, w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + channelId := params["channel_id"] + + if c.Session.GetTeamByTeamId(c.TeamId) == nil { + if !HasPermissionToContext(c, model.PERMISSION_MANAGE_SYSTEM) { + return + } + } + + if !HasPermissionToChannelContext(c, channelId, model.PERMISSION_READ_CHANNEL) { + return + } + + offset, err := strconv.Atoi(params["offset"]) + if err != nil { + c.SetInvalidParam("getProfiles", "offset") + return + } + + limit, err := strconv.Atoi(params["limit"]) + if err != nil { + c.SetInvalidParam("getProfiles", "limit") + return + } + + if result := <-Srv.Store.User().GetProfilesNotInChannel(c.TeamId, channelId, offset, limit); result.Err != nil { c.Err = result.Err return } else { @@ -1038,7 +1107,6 @@ func getDirectProfiles(c *Context, w http.ResponseWriter, r *http.Request) { profiles[k] = sanitizeProfile(c, p) } - w.Header().Set(model.HEADER_ETAG_SERVER, etag) w.Write([]byte(model.UserMapToJson(profiles))) } } @@ -2522,3 +2590,152 @@ func sanitizeProfile(c *Context, user *model.User) *model.User { return user } + +func searchUsers(c *Context, w http.ResponseWriter, r *http.Request) { + props := model.MapFromJson(r.Body) + + term := props["term"] + if len(term) == 0 { + c.SetInvalidParam("searchUsers", "term") + return + } + + teamId := props["team_id"] + inChannelId := props["in_channel"] + notInChannelId := props["not_in_channel"] + + if inChannelId != "" && !HasPermissionToChannelContext(c, inChannelId, model.PERMISSION_READ_CHANNEL) { + return + } + + if notInChannelId != "" && !HasPermissionToChannelContext(c, notInChannelId, model.PERMISSION_READ_CHANNEL) { + return + } + + var uchan store.StoreChannel + if inChannelId != "" { + uchan = Srv.Store.User().SearchInChannel(inChannelId, term, store.USER_SEARCH_TYPE_USERNAME) + } else if notInChannelId != "" { + uchan = Srv.Store.User().SearchNotInChannel(teamId, notInChannelId, term, store.USER_SEARCH_TYPE_USERNAME) + } else { + uchan = Srv.Store.User().Search(teamId, term, store.USER_SEARCH_TYPE_USERNAME) + } + + if result := <-uchan; result.Err != nil { + c.Err = result.Err + return + } else { + profiles := result.Data.([]*model.User) + + for _, p := range profiles { + sanitizeProfile(c, p) + } + + w.Write([]byte(model.UserListToJson(profiles))) + } +} + +func getProfilesByIds(c *Context, w http.ResponseWriter, r *http.Request) { + userIds := model.ArrayFromJson(r.Body) + + if len(userIds) == 0 { + c.SetInvalidParam("getProfilesByIds", "user_ids") + return + } + + if result := <-Srv.Store.User().GetProfileByIds(userIds); result.Err != nil { + c.Err = result.Err + return + } else { + profiles := result.Data.(map[string]*model.User) + + for _, p := range profiles { + sanitizeProfile(c, p) + } + + w.Write([]byte(model.UserMapToJson(profiles))) + } +} + +func autocompleteUsersInChannel(c *Context, w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + channelId := params["channel_id"] + teamId := params["team_id"] + + term := r.URL.Query().Get("term") + + if c.Session.GetTeamByTeamId(teamId) == nil { + if !HasPermissionToContext(c, model.PERMISSION_MANAGE_SYSTEM) { + return + } + } + + if !HasPermissionToChannelContext(c, channelId, model.PERMISSION_READ_CHANNEL) { + return + } + + uchan := Srv.Store.User().SearchInChannel(channelId, term, store.USER_SEARCH_TYPE_ALL) + nuchan := Srv.Store.User().SearchNotInChannel(teamId, channelId, term, store.USER_SEARCH_TYPE_ALL) + + autocomplete := &model.UserAutocompleteInChannel{} + + if result := <-uchan; result.Err != nil { + c.Err = result.Err + return + } else { + profiles := result.Data.([]*model.User) + + for _, p := range profiles { + sanitizeProfile(c, p) + } + + autocomplete.InChannel = profiles + } + + if result := <-nuchan; result.Err != nil { + c.Err = result.Err + return + } else { + profiles := result.Data.([]*model.User) + + for _, p := range profiles { + sanitizeProfile(c, p) + } + + autocomplete.OutOfChannel = profiles + } + + w.Write([]byte(autocomplete.ToJson())) +} + +func autocompleteUsersInTeam(c *Context, w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + teamId := params["team_id"] + + term := r.URL.Query().Get("term") + + if c.Session.GetTeamByTeamId(teamId) == nil { + if !HasPermissionToContext(c, model.PERMISSION_MANAGE_SYSTEM) { + return + } + } + + uchan := Srv.Store.User().Search(teamId, term, store.USER_SEARCH_TYPE_ALL) + + autocomplete := &model.UserAutocompleteInTeam{} + + if result := <-uchan; result.Err != nil { + c.Err = result.Err + return + } else { + profiles := result.Data.([]*model.User) + + for _, p := range profiles { + sanitizeProfile(c, p) + } + + autocomplete.InTeam = profiles + } + + w.Write([]byte(autocomplete.ToJson())) +} diff --git a/api/user_test.go b/api/user_test.go index 3800f5d9e8..3b6fcb1fbc 100644 --- a/api/user_test.go +++ b/api/user_test.go @@ -435,7 +435,7 @@ func TestGetUser(t *testing.T) { } } - if userMap, err := Client.GetProfiles(rteam.Data.(*model.Team).Id, ""); err != nil { + if userMap, err := Client.GetProfilesInTeam(rteam.Data.(*model.Team).Id, 0, 100, ""); err != nil { t.Fatal(err) } else if len(userMap.Data.(map[string]*model.User)) != 2 { t.Fatal("should have been 2") @@ -444,7 +444,7 @@ func TestGetUser(t *testing.T) { } else { // test etag caching - if cache_result, err := Client.GetProfiles(rteam.Data.(*model.Team).Id, userMap.Etag); err != nil { + if cache_result, err := Client.GetProfilesInTeam(rteam.Data.(*model.Team).Id, 0, 100, userMap.Etag); err != nil { t.Fatal(err) } else if cache_result.Data.(map[string]*model.User) != nil { t.Log(cache_result.Data) @@ -452,7 +452,25 @@ func TestGetUser(t *testing.T) { } } - if _, err := Client.GetProfiles(rteam2.Data.(*model.Team).Id, ""); err == nil { + if userMap, err := Client.GetProfilesInTeam(rteam.Data.(*model.Team).Id, 0, 1, ""); err != nil { + t.Fatal(err) + } else if len(userMap.Data.(map[string]*model.User)) != 1 { + t.Fatal("should have been 1") + } + + if userMap, err := Client.GetProfilesInTeam(rteam.Data.(*model.Team).Id, 1, 1, ""); err != nil { + t.Fatal(err) + } else if len(userMap.Data.(map[string]*model.User)) != 1 { + t.Fatal("should have been 1") + } + + if userMap, err := Client.GetProfilesInTeam(rteam.Data.(*model.Team).Id, 10, 10, ""); err != nil { + t.Fatal(err) + } else if len(userMap.Data.(map[string]*model.User)) != 0 { + t.Fatal("should have been 0") + } + + if _, err := Client.GetProfilesInTeam(rteam2.Data.(*model.Team).Id, 0, 100, ""); err == nil { t.Fatal("shouldn't have access") } @@ -468,12 +486,12 @@ func TestGetUser(t *testing.T) { Client.Login(user.Email, "passwd1") - if _, err := Client.GetProfiles(rteam2.Data.(*model.Team).Id, ""); err != nil { + if _, err := Client.GetProfilesInTeam(rteam2.Data.(*model.Team).Id, 0, 100, ""); err != nil { t.Fatal(err) } } -func TestGetDirectProfiles(t *testing.T) { +func TestGetProfiles(t *testing.T) { th := Setup().InitBasic() th.BasicClient.Must(th.BasicClient.CreateDirectChannel(th.BasicUser2.Id)) @@ -485,62 +503,7 @@ func TestGetDirectProfiles(t *testing.T) { utils.Cfg.PrivacySettings.ShowEmailAddress = true - if result, err := th.BasicClient.GetDirectProfiles(""); err != nil { - t.Fatal(err) - } else { - users := result.Data.(map[string]*model.User) - - if len(users) != 1 { - t.Fatal("map was wrong length") - } - - if users[th.BasicUser2.Id] == nil { - t.Fatal("missing expected user") - } - - for _, user := range users { - if user.Email == "" { - t.Fatal("problem with show email") - } - } - } - - utils.Cfg.PrivacySettings.ShowEmailAddress = false - - if result, err := th.BasicClient.GetDirectProfiles(""); err != nil { - t.Fatal(err) - } else { - users := result.Data.(map[string]*model.User) - - if len(users) != 1 { - t.Fatal("map was wrong length") - } - - if users[th.BasicUser2.Id] == nil { - t.Fatal("missing expected user") - } - - for _, user := range users { - if user.Email != "" { - t.Fatal("problem with show email") - } - } - } -} - -func TestGetProfilesForDirectMessageList(t *testing.T) { - th := Setup().InitBasic() - - th.BasicClient.Must(th.BasicClient.CreateDirectChannel(th.BasicUser2.Id)) - - prevShowEmail := utils.Cfg.PrivacySettings.ShowEmailAddress - defer func() { - utils.Cfg.PrivacySettings.ShowEmailAddress = prevShowEmail - }() - - utils.Cfg.PrivacySettings.ShowEmailAddress = true - - if result, err := th.BasicClient.GetProfilesForDirectMessageList(th.BasicTeam.Id); err != nil { + if result, err := th.BasicClient.GetProfiles(0, 100, ""); err != nil { t.Fatal(err) } else { users := result.Data.(map[string]*model.User) @@ -554,11 +517,20 @@ func TestGetProfilesForDirectMessageList(t *testing.T) { t.Fatal("problem with show email") } } + + // test etag caching + if cache_result, err := th.BasicClient.GetProfiles(0, 100, result.Etag); err != nil { + t.Fatal(err) + } else if cache_result.Data.(map[string]*model.User) != nil { + t.Log(cache_result.Etag) + t.Log(result.Etag) + t.Fatal("cache should be empty") + } } utils.Cfg.PrivacySettings.ShowEmailAddress = false - if result, err := th.BasicClient.GetProfilesForDirectMessageList(th.BasicTeam.Id); err != nil { + if result, err := th.BasicClient.GetProfiles(0, 100, ""); err != nil { t.Fatal(err) } else { users := result.Data.(map[string]*model.User) @@ -575,6 +547,61 @@ func TestGetProfilesForDirectMessageList(t *testing.T) { } } +func TestGetProfilesByIds(t *testing.T) { + th := Setup().InitBasic() + + prevShowEmail := utils.Cfg.PrivacySettings.ShowEmailAddress + defer func() { + utils.Cfg.PrivacySettings.ShowEmailAddress = prevShowEmail + }() + + utils.Cfg.PrivacySettings.ShowEmailAddress = true + + if result, err := th.BasicClient.GetProfilesByIds([]string{th.BasicUser.Id}); err != nil { + t.Fatal(err) + } else { + users := result.Data.(map[string]*model.User) + + if len(users) != 1 { + t.Fatal("map was wrong length") + } + + for _, user := range users { + if user.Email == "" { + t.Fatal("problem with show email") + } + } + } + + utils.Cfg.PrivacySettings.ShowEmailAddress = false + + if result, err := th.BasicClient.GetProfilesByIds([]string{th.BasicUser.Id}); err != nil { + t.Fatal(err) + } else { + users := result.Data.(map[string]*model.User) + + if len(users) != 1 { + t.Fatal("map was wrong length") + } + + for _, user := range users { + if user.Email != "" { + t.Fatal("problem with show email") + } + } + } + + if result, err := th.BasicClient.GetProfilesByIds([]string{th.BasicUser.Id, th.BasicUser2.Id}); err != nil { + t.Fatal(err) + } else { + users := result.Data.(map[string]*model.User) + + if len(users) != 2 { + t.Fatal("map was wrong length") + } + } +} + func TestGetAudits(t *testing.T) { th := Setup() Client := th.CreateClient() @@ -1837,3 +1864,366 @@ func TestUserTyping(t *testing.T) { t.Fatal("did not receive typing event") } } + +func TestGetProfilesInChannel(t *testing.T) { + th := Setup().InitBasic() + Client := th.BasicClient + + prevShowEmail := utils.Cfg.PrivacySettings.ShowEmailAddress + defer func() { + utils.Cfg.PrivacySettings.ShowEmailAddress = prevShowEmail + }() + + utils.Cfg.PrivacySettings.ShowEmailAddress = true + + if result, err := Client.GetProfilesInChannel(th.BasicChannel.Id, 0, 100, ""); err != nil { + t.Fatal(err) + } else { + users := result.Data.(map[string]*model.User) + + if len(users) < 1 { + t.Fatal("map was wrong length") + } + + for _, user := range users { + if user.Email == "" { + t.Fatal("problem with show email") + } + } + } + + th.LoginBasic2() + + if _, err := Client.GetProfilesInChannel(th.BasicChannel.Id, 0, 100, ""); err == nil { + t.Fatal("should not have access") + } + + Client.Must(Client.JoinChannel(th.BasicChannel.Id)) + + utils.Cfg.PrivacySettings.ShowEmailAddress = false + + if result, err := Client.GetProfilesInChannel(th.BasicChannel.Id, 0, 100, ""); err != nil { + t.Fatal(err) + } else { + users := result.Data.(map[string]*model.User) + + if len(users) < 1 { + t.Fatal("map was wrong length") + } + + found := false + for _, user := range users { + if user.Email != "" { + t.Fatal("problem with show email") + } + if user.Id == th.BasicUser2.Id { + found = true + } + } + + if !found { + t.Fatal("should have found profile") + } + } + + user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "passwd1"} + Client.Must(Client.CreateUser(&user, "")) + + Client.Login(user.Email, "passwd1") + Client.SetTeamId("junk") + + if _, err := Client.GetProfilesInChannel(th.BasicChannel.Id, 0, 100, ""); err == nil { + t.Fatal("should not have access") + } +} + +func TestGetProfilesNotInChannel(t *testing.T) { + th := Setup().InitBasic() + Client := th.BasicClient + + prevShowEmail := utils.Cfg.PrivacySettings.ShowEmailAddress + defer func() { + utils.Cfg.PrivacySettings.ShowEmailAddress = prevShowEmail + }() + + utils.Cfg.PrivacySettings.ShowEmailAddress = true + + if result, err := Client.GetProfilesNotInChannel(th.BasicChannel.Id, 0, 100, ""); err != nil { + t.Fatal(err) + } else { + users := result.Data.(map[string]*model.User) + + if len(users) < 1 { + t.Fatal("map was wrong length") + } + + found := false + for _, user := range users { + if user.Email == "" { + t.Fatal("problem with show email") + } + if user.Id == th.BasicUser2.Id { + found = true + } + } + + if !found { + t.Fatal("should have found profile") + } + } + + user := &model.User{Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "passwd1"} + user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User) + LinkUserToTeam(user, th.BasicTeam) + + th.LoginBasic2() + + if _, err := Client.GetProfilesNotInChannel(th.BasicChannel.Id, 0, 100, ""); err == nil { + t.Fatal("should not have access") + } + + Client.Must(Client.JoinChannel(th.BasicChannel.Id)) + + utils.Cfg.PrivacySettings.ShowEmailAddress = false + + if result, err := Client.GetProfilesNotInChannel(th.BasicChannel.Id, 0, 100, ""); err != nil { + t.Fatal(err) + } else { + users := result.Data.(map[string]*model.User) + + if len(users) < 1 { + t.Fatal("map was wrong length") + } + + found := false + for _, user := range users { + if user.Email != "" { + t.Fatal("problem with show email") + } + if user.Id == th.BasicUser2.Id { + found = true + } + } + + if found { + t.Fatal("should not have found profile") + } + } + + user2 := model.User{Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "passwd1"} + Client.Must(Client.CreateUser(&user2, "")) + + Client.Login(user2.Email, "passwd1") + Client.SetTeamId(th.BasicTeam.Id) + + if _, err := Client.GetProfilesNotInChannel(th.BasicChannel.Id, 0, 100, ""); err == nil { + t.Fatal("should not have access") + } +} + +func TestSearchUsers(t *testing.T) { + th := Setup().InitBasic() + Client := th.BasicClient + + if result, err := Client.SearchUsers(th.BasicUser.Username, "", map[string]string{}); err != nil { + t.Fatal(err) + } else { + users := result.Data.([]*model.User) + + found := false + for _, user := range users { + if user.Id == th.BasicUser.Id { + found = true + } + } + + if !found { + t.Fatal("should have found profile") + } + } + + if result, err := Client.SearchUsers(th.BasicUser.Username, "", map[string]string{"in_channel": th.BasicChannel.Id}); err != nil { + t.Fatal(err) + } else { + users := result.Data.([]*model.User) + + if len(users) != 1 { + t.Fatal("map was wrong length") + } + + found := false + for _, user := range users { + if user.Id == th.BasicUser.Id { + found = true + } + } + + if !found { + t.Fatal("should have found profile") + } + } + + if result, err := Client.SearchUsers(th.BasicUser2.Username, "", map[string]string{"not_in_channel": th.BasicChannel.Id}); err != nil { + t.Fatal(err) + } else { + users := result.Data.([]*model.User) + + if len(users) != 1 { + t.Fatal("map was wrong length") + } + + found1 := false + found2 := false + for _, user := range users { + if user.Id == th.BasicUser.Id { + found1 = true + } else if user.Id == th.BasicUser2.Id { + found2 = true + } + } + + if found1 { + t.Fatal("should not have found profile") + } + if !found2 { + t.Fatal("should have found profile") + } + } + + if result, err := Client.SearchUsers(th.BasicUser2.Username, th.BasicTeam.Id, map[string]string{"not_in_channel": th.BasicChannel.Id}); err != nil { + t.Fatal(err) + } else { + users := result.Data.([]*model.User) + + if len(users) != 1 { + t.Fatal("map was wrong length") + } + + found1 := false + found2 := false + for _, user := range users { + if user.Id == th.BasicUser.Id { + found1 = true + } else if user.Id == th.BasicUser2.Id { + found2 = true + } + } + + if found1 { + t.Fatal("should not have found profile") + } + if !found2 { + t.Fatal("should have found profile") + } + } + + if result, err := Client.SearchUsers(th.BasicUser.Username, "junk", map[string]string{"not_in_channel": th.BasicChannel.Id}); err != nil { + t.Fatal(err) + } else { + users := result.Data.([]*model.User) + + if len(users) != 0 { + t.Fatal("map was wrong length") + } + } + + th.LoginBasic2() + + if result, err := Client.SearchUsers(th.BasicUser.Username, "", map[string]string{}); err != nil { + t.Fatal(err) + } else { + users := result.Data.([]*model.User) + + found := false + for _, user := range users { + if user.Id == th.BasicUser.Id { + found = true + } + } + + if !found { + t.Fatal("should have found profile") + } + } + + if _, err := Client.SearchUsers("", "", map[string]string{}); err == nil { + t.Fatal("should have errored - blank term") + } + + if _, err := Client.SearchUsers(th.BasicUser.Username, "", map[string]string{"in_channel": th.BasicChannel.Id}); err == nil { + t.Fatal("should not have access") + } + + if _, err := Client.SearchUsers(th.BasicUser.Username, "", map[string]string{"not_in_channel": th.BasicChannel.Id}); err == nil { + t.Fatal("should not have access") + } +} + +func TestAutocompleteUsers(t *testing.T) { + th := Setup().InitBasic() + Client := th.BasicClient + + if result, err := Client.AutocompleteUsersInTeam(th.BasicUser.Username); err != nil { + t.Fatal(err) + } else { + autocomplete := result.Data.(*model.UserAutocompleteInTeam) + if len(autocomplete.InTeam) != 1 { + t.Fatal("should have returned 1 user in") + } + } + + if result, err := Client.AutocompleteUsersInTeam(th.BasicUser.Username[0:5]); err != nil { + t.Fatal(err) + } else { + autocomplete := result.Data.(*model.UserAutocompleteInTeam) + if len(autocomplete.InTeam) < 1 { + t.Fatal("should have returned at least 1 user in") + } + } + + if result, err := Client.AutocompleteUsersInChannel(th.BasicUser.Username, th.BasicChannel.Id); err != nil { + t.Fatal(err) + } else { + autocomplete := result.Data.(*model.UserAutocompleteInChannel) + if len(autocomplete.InChannel) != 1 { + t.Fatal("should have returned 1 user in") + } + if len(autocomplete.OutOfChannel) != 0 { + t.Fatal("should have returned no users out") + } + } + + if result, err := Client.AutocompleteUsersInChannel("", th.BasicChannel.Id); err != nil { + t.Fatal(err) + } else { + autocomplete := result.Data.(*model.UserAutocompleteInChannel) + if len(autocomplete.InChannel) != 1 && autocomplete.InChannel[0].Id != th.BasicUser2.Id { + t.Fatal("should have returned at 1 user in") + } + if len(autocomplete.OutOfChannel) != 1 && autocomplete.OutOfChannel[0].Id != th.BasicUser2.Id { + t.Fatal("should have returned 1 user out") + } + } + + if result, err := Client.AutocompleteUsersInTeam(""); err != nil { + t.Fatal(err) + } else { + autocomplete := result.Data.(*model.UserAutocompleteInTeam) + if len(autocomplete.InTeam) != 2 { + t.Fatal("should have returned 2 users in") + } + } + + if _, err := Client.AutocompleteUsersInChannel("", "junk"); err == nil { + t.Fatal("should have errored - bad channel id") + } + + Client.SetTeamId("junk") + if _, err := Client.AutocompleteUsersInChannel("", th.BasicChannel.Id); err == nil { + t.Fatal("should have errored - bad team id") + } + + if _, err := Client.AutocompleteUsersInTeam(""); err == nil { + t.Fatal("should have errored - bad team id") + } +} diff --git a/api/web_conn.go b/api/web_conn.go index f4bd493bb5..7f3c1f8758 100644 --- a/api/web_conn.go +++ b/api/web_conn.go @@ -4,54 +4,52 @@ package api import ( + "fmt" "time" "github.com/mattermost/platform/model" + l4g "github.com/alecthomas/log4go" "github.com/gorilla/websocket" goi18n "github.com/nicksnyder/go-i18n/i18n" ) const ( - WRITE_WAIT = 10 * time.Second - PONG_WAIT = 60 * time.Second - PING_PERIOD = (PONG_WAIT * 9) / 10 - MAX_SIZE = 512 - REDIS_WAIT = 60 * time.Second + WRITE_WAIT = 30 * time.Second + PONG_WAIT = 100 * time.Second + PING_PERIOD = (PONG_WAIT * 6) / 10 ) type WebConn struct { - WebSocket *websocket.Conn - Send chan model.WebSocketMessage - SessionToken string - UserId string - T goi18n.TranslateFunc - Locale string - isMemberOfChannel map[string]bool - isMemberOfTeam map[string]bool + WebSocket *websocket.Conn + Send chan model.WebSocketMessage + SessionToken string + UserId string + T goi18n.TranslateFunc + Locale string + AllChannelMembers map[string]string + LastAllChannelMembersTime int64 } func NewWebConn(c *Context, ws *websocket.Conn) *WebConn { go SetStatusOnline(c.Session.UserId, c.Session.Id, false) return &WebConn{ - Send: make(chan model.WebSocketMessage, 64), - WebSocket: ws, - UserId: c.Session.UserId, - SessionToken: c.Session.Token, - T: c.T, - Locale: c.Locale, - isMemberOfChannel: make(map[string]bool), - isMemberOfTeam: make(map[string]bool), + Send: make(chan model.WebSocketMessage, 256), + WebSocket: ws, + UserId: c.Session.UserId, + SessionToken: c.Session.Token, + T: c.T, + Locale: c.Locale, } } func (c *WebConn) readPump() { defer func() { - hub.Unregister(c) + HubUnregister(c) c.WebSocket.Close() }() - c.WebSocket.SetReadLimit(MAX_SIZE) + c.WebSocket.SetReadLimit(SOCKET_MAX_MESSAGE_SIZE_KB) c.WebSocket.SetReadDeadline(time.Now().Add(PONG_WAIT)) c.WebSocket.SetPongHandler(func(string) error { c.WebSocket.SetReadDeadline(time.Now().Add(PONG_WAIT)) @@ -62,6 +60,13 @@ func (c *WebConn) readPump() { for { var req model.WebSocketRequest if err := c.WebSocket.ReadJSON(&req); err != nil { + // browsers will appear as CloseNoStatusReceived + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseNoStatusReceived) { + l4g.Debug(fmt.Sprintf("websocket.read: client side closed socket userId=%v", c.UserId)) + } else { + l4g.Debug(fmt.Sprintf("websocket.read: cannot read, closing websocket for userId=%v error=%v", c.UserId, err.Error())) + } + return } else { BaseRoutes.WebSocket.ServeWebSocket(c, &req) @@ -87,63 +92,97 @@ func (c *WebConn) writePump() { } c.WebSocket.SetWriteDeadline(time.Now().Add(WRITE_WAIT)) - if err := c.WebSocket.WriteJSON(msg); err != nil { + if err := c.WebSocket.WriteMessage(websocket.TextMessage, msg.GetPreComputeJson()); err != nil { + // browsers will appear as CloseNoStatusReceived + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseNoStatusReceived) { + l4g.Debug(fmt.Sprintf("websocket.send: client side closed socket userId=%v", c.UserId)) + } else { + l4g.Debug(fmt.Sprintf("websocket.send: cannot send, closing websocket for userId=%v, error=%v", c.UserId, err.Error())) + } + return } case <-ticker.C: c.WebSocket.SetWriteDeadline(time.Now().Add(WRITE_WAIT)) if err := c.WebSocket.WriteMessage(websocket.PingMessage, []byte{}); err != nil { + // browsers will appear as CloseNoStatusReceived + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseNoStatusReceived) { + l4g.Debug(fmt.Sprintf("websocket.ticker: client side closed socket userId=%v", c.UserId)) + } else { + l4g.Debug(fmt.Sprintf("websocket.ticker: cannot read, closing websocket for userId=%v error=%v", c.UserId, err.Error())) + } + return } } } } -func (c *WebConn) InvalidateCache() { - c.isMemberOfTeam = make(map[string]bool) - c.isMemberOfChannel = make(map[string]bool) +func (webCon *WebConn) InvalidateCache() { + webCon.AllChannelMembers = nil + webCon.LastAllChannelMembersTime = 0 + } -func (c *WebConn) InvalidateCacheForChannel(channelId string) { - delete(c.isMemberOfChannel, channelId) -} +func (webCon *WebConn) ShouldSendEvent(msg *model.WebSocketEvent) bool { + // If the event is destined to a specific user + if len(msg.Broadcast.UserId) > 0 && webCon.UserId != msg.Broadcast.UserId { + return false + } -func (c *WebConn) IsMemberOfTeam(teamId string) bool { - isMember, ok := c.isMemberOfTeam[teamId] - if !ok { - session := GetSession(c.SessionToken) - if session == nil { - isMember = false - c.isMemberOfTeam[teamId] = isMember - } else { - member := session.GetTeamByTeamId(teamId) + // if the user is omitted don't send the message + if len(msg.Broadcast.OmitUsers) > 0 { + if _, ok := msg.Broadcast.OmitUsers[webCon.UserId]; ok { + return false + } + } - if member != nil { - isMember = true - c.isMemberOfTeam[teamId] = isMember + // Only report events to users who are in the channel for the event + if len(msg.Broadcast.ChannelId) > 0 { + + if model.GetMillis()-webCon.LastAllChannelMembersTime > 1000*60*15 { // 15 minutes + webCon.AllChannelMembers = nil + webCon.LastAllChannelMembersTime = 0 + } + + if webCon.AllChannelMembers == nil { + if result := <-Srv.Store.Channel().GetAllChannelMembersForUser(webCon.UserId, true); result.Err != nil { + l4g.Error("webhub.shouldSendEvent: " + result.Err.Error()) + return false } else { - isMember = true - c.isMemberOfTeam[teamId] = isMember + webCon.AllChannelMembers = result.Data.(map[string]string) + webCon.LastAllChannelMembersTime = model.GetMillis() } - } - } - return isMember -} - -func (c *WebConn) IsMemberOfChannel(channelId string) bool { - isMember, ok := c.isMemberOfChannel[channelId] - if !ok { - if cresult := <-Srv.Store.Channel().GetMember(channelId, c.UserId); cresult.Err != nil { - isMember = false - c.isMemberOfChannel[channelId] = isMember + if _, ok := webCon.AllChannelMembers[msg.Broadcast.ChannelId]; ok { + return true } else { - isMember = true - c.isMemberOfChannel[channelId] = isMember + return false } } - return isMember + // Only report events to users who are in the team for the event + if len(msg.Broadcast.TeamId) > 0 { + return webCon.IsMemberOfTeam(msg.Broadcast.TeamId) + + } + + return true +} + +func (webCon *WebConn) IsMemberOfTeam(teamId string) bool { + session := GetSession(webCon.SessionToken) + if session == nil { + return false + } else { + member := session.GetTeamByTeamId(teamId) + + if member != nil { + return true + } else { + return false + } + } } diff --git a/api/web_hub.go b/api/web_hub.go index 4a9719d808..5f480880e1 100644 --- a/api/web_hub.go +++ b/api/web_hub.go @@ -5,6 +5,8 @@ package api import ( "fmt" + "hash/fnv" + "runtime" l4g "github.com/alecthomas/log4go" @@ -14,27 +16,77 @@ import ( ) type Hub struct { - connections map[*WebConn]bool - register chan *WebConn - unregister chan *WebConn - broadcast chan *model.WebSocketEvent - stop chan string - invalidateUser chan string - invalidateChannel chan string + connections map[*WebConn]bool + register chan *WebConn + unregister chan *WebConn + broadcast chan *model.WebSocketEvent + stop chan string + invalidateUser chan string } -var hub = &Hub{ - register: make(chan *WebConn), - unregister: make(chan *WebConn), - connections: make(map[*WebConn]bool), - broadcast: make(chan *model.WebSocketEvent), - stop: make(chan string), - invalidateUser: make(chan string), - invalidateChannel: make(chan string), +var hubs []*Hub = make([]*Hub, 0) + +func NewWebHub() *Hub { + return &Hub{ + register: make(chan *WebConn), + unregister: make(chan *WebConn), + connections: make(map[*WebConn]bool, model.SESSION_CACHE_SIZE), + broadcast: make(chan *model.WebSocketEvent, 4096), + stop: make(chan string), + invalidateUser: make(chan string), + } +} + +func TotalWebsocketConnections() int { + // XXX TODO FIXME, this is racy and needs to be fixed + count := 0 + for _, hub := range hubs { + count = count + len(hub.connections) + } + + return count +} + +func HubStart() { + l4g.Info(utils.T("api.web_hub.start.starting.debug"), runtime.NumCPU()*2) + + // Total number of hubs is twice the number of CPUs. + hubs = make([]*Hub, runtime.NumCPU()*2) + + for i := 0; i < len(hubs); i++ { + hubs[i] = NewWebHub() + hubs[i].Start() + } +} + +func HubStop() { + l4g.Info(utils.T("api.web_hub.start.stopping.debug")) + + for _, hub := range hubs { + hub.Stop() + } + + hubs = make([]*Hub, 0) +} + +func HubRegister(webConn *WebConn) { + hash := fnv.New32a() + hash.Write([]byte(webConn.UserId)) + index := hash.Sum32() % uint32(len(hubs)) + hubs[index].Register(webConn) +} + +func HubUnregister(webConn *WebConn) { + for _, hub := range hubs { + hub.Unregister(webConn) + } } func Publish(message *model.WebSocketEvent) { - hub.Broadcast(message) + message.DoPreComputeJson() + for _, hub := range hubs { + hub.Broadcast(message) + } if einterfaces.GetClusterInterface() != nil { einterfaces.GetClusterInterface().Publish(message) @@ -42,11 +94,19 @@ func Publish(message *model.WebSocketEvent) { } func PublishSkipClusterSend(message *model.WebSocketEvent) { - hub.Broadcast(message) + message.DoPreComputeJson() + for _, hub := range hubs { + hub.Broadcast(message) + } } func InvalidateCacheForUser(userId string) { - hub.invalidateUser <- userId + + Srv.Store.Channel().InvalidateAllChannelMembersForUser(userId) + + for _, hub := range hubs { + hub.InvalidateUser(userId) + } if einterfaces.GetClusterInterface() != nil { einterfaces.GetClusterInterface().InvalidateCacheForUser(userId) @@ -54,11 +114,17 @@ func InvalidateCacheForUser(userId string) { } func InvalidateCacheForChannel(channelId string) { - hub.invalidateChannel <- channelId - if einterfaces.GetClusterInterface() != nil { - einterfaces.GetClusterInterface().InvalidateCacheForChannel(channelId) - } + // XXX TODO FIX ME + // This can be removed, but the performance branch + // needs to be merged into master so it can be removed + // from the enterprise repo as well. + + // hub.invalidateChannel <- channelId + + // if einterfaces.GetClusterInterface() != nil { + // einterfaces.GetClusterInterface().InvalidateCacheForChannel(channelId) + // } } func (h *Hub) Register(webConn *WebConn) { @@ -79,6 +145,10 @@ func (h *Hub) Broadcast(message *model.WebSocketEvent) { } } +func (h *Hub) InvalidateUser(userId string) { + h.invalidateUser <- userId +} + func (h *Hub) Stop() { h.stop <- "all" } @@ -108,6 +178,7 @@ func (h *Hub) Start() { if !found { go SetStatusOffline(userId, false) } + case userId := <-h.invalidateUser: for webCon := range h.connections { if webCon.UserId == userId { @@ -115,26 +186,20 @@ func (h *Hub) Start() { } } - case channelId := <-h.invalidateChannel: - for webCon := range h.connections { - webCon.InvalidateCacheForChannel(channelId) - } - case msg := <-h.broadcast: for webCon := range h.connections { - if shouldSendEvent(webCon, msg) { + if webCon.ShouldSendEvent(msg) { select { case webCon.Send <- msg: default: + l4g.Error(fmt.Sprintf("webhub.broadcast: cannot send, closing websocket for userId=%v", webCon.UserId)) close(webCon.Send) delete(h.connections, webCon) } } } - case s := <-h.stop: - l4g.Debug(utils.T("api.web_hub.start.stopping.debug"), s) - + case <-h.stop: for webCon := range h.connections { webCon.WebSocket.Close() } @@ -144,28 +209,3 @@ func (h *Hub) Start() { } }() } - -func shouldSendEvent(webCon *WebConn, msg *model.WebSocketEvent) bool { - // If the event is destined to a specific user - if len(msg.Broadcast.UserId) > 0 && webCon.UserId != msg.Broadcast.UserId { - return false - } - - // if the user is omitted don't send the message - if _, ok := msg.Broadcast.OmitUsers[webCon.UserId]; ok { - return false - } - - // Only report events to users who are in the channel for the event - if len(msg.Broadcast.ChannelId) > 0 { - return webCon.IsMemberOfChannel(msg.Broadcast.ChannelId) - } - - // Only report events to users who are in the team for the event - if len(msg.Broadcast.TeamId) > 0 { - return webCon.IsMemberOfTeam(msg.Broadcast.TeamId) - - } - - return true -} diff --git a/api/websocket.go b/api/websocket.go index fe9fa0bf95..34d95f705e 100644 --- a/api/websocket.go +++ b/api/websocket.go @@ -11,16 +11,20 @@ import ( "net/http" ) +const ( + SOCKET_MAX_MESSAGE_SIZE_KB = 8 * 1024 // 8KB +) + func InitWebSocket() { l4g.Debug(utils.T("api.web_socket.init.debug")) BaseRoutes.Users.Handle("/websocket", ApiUserRequiredTrustRequester(connect)).Methods("GET") - hub.Start() + HubStart() } func connect(c *Context, w http.ResponseWriter, r *http.Request) { upgrader := websocket.Upgrader{ - ReadBufferSize: 1024, - WriteBufferSize: 1024, + ReadBufferSize: SOCKET_MAX_MESSAGE_SIZE_KB, + WriteBufferSize: SOCKET_MAX_MESSAGE_SIZE_KB, CheckOrigin: func(r *http.Request) bool { return true }, @@ -34,7 +38,7 @@ func connect(c *Context, w http.ResponseWriter, r *http.Request) { } wc := NewWebConn(c, ws) - hub.Register(wc) + HubRegister(wc) go wc.writePump() wc.readPump() } diff --git a/api/websocket_handler.go b/api/websocket_handler.go index 5a313fe131..95aad8feea 100644 --- a/api/websocket_handler.go +++ b/api/websocket_handler.go @@ -31,11 +31,17 @@ func (wh *webSocketHandler) ServeWebSocket(conn *WebConn, r *model.WebSocketRequ if data, err = wh.handlerFunc(r); err != nil { l4g.Error(utils.T("api.web_socket_handler.log.error"), "/api/v3/users/websocket", r.Action, r.Seq, r.Session.UserId, err.SystemMessage(utils.T), err.DetailedError) err.DetailedError = "" - conn.Send <- model.NewWebSocketError(r.Seq, err) + errResp := model.NewWebSocketError(r.Seq, err) + errResp.DoPreComputeJson() + + conn.Send <- errResp return } - conn.Send <- model.NewWebSocketResponse(model.STATUS_OK, r.Seq, data) + resp := model.NewWebSocketResponse(model.STATUS_OK, r.Seq, data) + resp.DoPreComputeJson() + + conn.Send <- resp } func NewInvalidWebSocketParamError(action string, name string) *model.AppError { diff --git a/api/websocket_router.go b/api/websocket_router.go index cd3ff4d1a5..34b576464b 100644 --- a/api/websocket_router.go +++ b/api/websocket_router.go @@ -54,6 +54,7 @@ func (wr *WebSocketRouter) ReturnWebSocketError(conn *WebConn, r *model.WebSocke err.DetailedError = "" errorResp := model.NewWebSocketError(r.Seq, err) + errorResp.DoPreComputeJson() conn.Send <- errorResp } diff --git a/api/websocket_test.go b/api/websocket_test.go index dcbc8e0f4c..b7ca4b691a 100644 --- a/api/websocket_test.go +++ b/api/websocket_test.go @@ -82,7 +82,8 @@ func TestWebSocketEvent(t *testing.T) { omitUser["somerandomid"] = true evt1 := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_TYPING, "", th.BasicChannel.Id, "", omitUser) evt1.Add("user_id", "somerandomid") - go Publish(evt1) + Publish(evt1) + time.Sleep(300 * time.Millisecond) stop := make(chan bool) diff --git a/config/config.json b/config/config.json index 330ae78a2d..195c0255e8 100644 --- a/config/config.json +++ b/config/config.json @@ -64,7 +64,7 @@ }, "LogSettings": { "EnableConsole": true, - "ConsoleLevel": "DEBUG", + "ConsoleLevel": "INFO", "EnableFile": true, "FileLevel": "INFO", "FileFormat": "", @@ -240,4 +240,4 @@ "TurnUsername": "", "TurnSharedKey": "" } -} \ No newline at end of file +} diff --git a/i18n/en.json b/i18n/en.json index 4e213d4462..5d27af7f13 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2403,9 +2403,13 @@ "id": "api.user.verify_email.bad_link.app_error", "translation": "Bad verify email link." }, + { + "id": "api.web_hub.start.starting.debug", + "translation": "Starting %v websocket hubs" + }, { "id": "api.web_hub.start.stopping.debug", - "translation": "stopping %v connections" + "translation": "stopping websocket hub connections" }, { "id": "api.web_socket.connect.error", @@ -4627,6 +4631,10 @@ "id": "store.sql_team.get_member.app_error", "translation": "We couldn't get the team member" }, + { + "id": "store.sql_team.get_members_by_ids.app_error", + "translation": "We couldn't get the team members" + }, { "id": "store.sql_team.get_member.missing.app_error", "translation": "No team member found for that user id and team id" @@ -4635,6 +4643,10 @@ "id": "store.sql_team.get_members.app_error", "translation": "We couldn't get the team members" }, + { + "id": "store.sql_team.get_member_count.app_error", + "translation": "We couldn't count the team members" + }, { "id": "store.sql_team.get_teams_for_email.app_error", "translation": "We encountered a problem when looking up teams" @@ -4723,6 +4735,10 @@ "id": "store.sql_user.get_profiles.app_error", "translation": "We encountered an error while finding user profiles" }, + { + "id": "store.sql_user.get_recently_active_users.app_error", + "translation": "We encountered an error while finding the recently active users" + }, { "id": "store.sql_user.get_sysadmin_profiles.app_error", "translation": "We encountered an error while finding user profiles" diff --git a/mattermost.go b/mattermost.go index 8d48805042..7b9f6c0c18 100644 --- a/mattermost.go +++ b/mattermost.go @@ -14,6 +14,7 @@ import ( "os/exec" "os/signal" "runtime" + "runtime/pprof" "strconv" "strings" "syscall" @@ -83,6 +84,10 @@ var flagChannelHeader string var flagChannelPurpose string var flagUserSetInactive bool var flagImportArchive string +var flagCpuProfile bool +var flagMemProfile bool +var flagBlockProfile bool +var flagHttpProfiler bool func doLoadConfig(filename string) (err string) { defer func() { @@ -122,7 +127,26 @@ func main() { cmdUpdateDb30() - api.NewServer() + if flagCpuProfile { + f, err := os.Create(utils.GetLogFileLocation(utils.Cfg.LogSettings.FileLocation) + ".cpu.prof") + if err != nil { + l4g.Error("Error creating cpu profile log: " + err.Error()) + } + + l4g.Info("CPU Profiler is logging to " + utils.GetLogFileLocation(utils.Cfg.LogSettings.FileLocation) + ".cpu.prof") + pprof.StartCPUProfile(f) + } + + if flagBlockProfile { + l4g.Info("Block Profiler is logging to " + utils.GetLogFileLocation(utils.Cfg.LogSettings.FileLocation) + ".blk.prof") + runtime.SetBlockProfileRate(1) + } + + if flagMemProfile { + l4g.Info("Memory Profiler is logging to " + utils.GetLogFileLocation(utils.Cfg.LogSettings.FileLocation) + ".mem.prof") + } + + api.NewServer(flagHttpProfiler) api.InitApi() web.InitWeb() @@ -169,6 +193,37 @@ func main() { } api.StopServer() + + if flagCpuProfile { + l4g.Info("Closing CPU Profiler") + pprof.StopCPUProfile() + } + + if flagBlockProfile { + f, err := os.Create(utils.GetLogFileLocation(utils.Cfg.LogSettings.FileLocation) + ".blk.prof") + if err != nil { + l4g.Error("Error creating block profile log: " + err.Error()) + } + + l4g.Info("Writing Block Profiler to: " + utils.GetLogFileLocation(utils.Cfg.LogSettings.FileLocation) + ".blk.prof") + pprof.Lookup("block").WriteTo(f, 0) + f.Close() + runtime.SetBlockProfileRate(0) + } + + if flagMemProfile { + f, err := os.Create(utils.GetLogFileLocation(utils.Cfg.LogSettings.FileLocation) + ".mem.prof") + if err != nil { + l4g.Error("Error creating memory profile file: " + err.Error()) + } + + l4g.Info("Writing Memory Profiler to: " + utils.GetLogFileLocation(utils.Cfg.LogSettings.FileLocation) + ".mem.prof") + runtime.GC() + if err := pprof.WriteHeapProfile(f); err != nil { + l4g.Error("Error creating memory profile: " + err.Error()) + } + f.Close() + } } } @@ -380,6 +435,10 @@ func parseCmds() { flag.BoolVar(&flagCmdActivateUser, "activate_user", false, "") flag.BoolVar(&flagCmdSlackImport, "slack_import", false, "") flag.BoolVar(&flagUserSetInactive, "inactive", false, "") + flag.BoolVar(&flagCpuProfile, "cpuprofile", false, "") + flag.BoolVar(&flagMemProfile, "memprofile", false, "") + flag.BoolVar(&flagBlockProfile, "blkprofile", false, "") + flag.BoolVar(&flagHttpProfiler, "httpprofiler", false, "") flag.Parse() diff --git a/model/autocomplete.go b/model/autocomplete.go new file mode 100644 index 0000000000..b7449a7920 --- /dev/null +++ b/model/autocomplete.go @@ -0,0 +1,58 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/json" + "io" +) + +type UserAutocompleteInChannel struct { + InChannel []*User `json:"in_channel"` + OutOfChannel []*User `json:"out_of_channel"` +} + +type UserAutocompleteInTeam struct { + InTeam []*User `json:"in_team"` +} + +func (o *UserAutocompleteInChannel) ToJson() string { + b, err := json.Marshal(o) + if err != nil { + return "" + } else { + return string(b) + } +} + +func UserAutocompleteInChannelFromJson(data io.Reader) *UserAutocompleteInChannel { + decoder := json.NewDecoder(data) + var o UserAutocompleteInChannel + err := decoder.Decode(&o) + if err == nil { + return &o + } else { + return nil + } +} + +func (o *UserAutocompleteInTeam) ToJson() string { + b, err := json.Marshal(o) + if err != nil { + return "" + } else { + return string(b) + } +} + +func UserAutocompleteInTeamFromJson(data io.Reader) *UserAutocompleteInTeam { + decoder := json.NewDecoder(data) + var o UserAutocompleteInTeam + err := decoder.Decode(&o) + if err == nil { + return &o + } else { + return nil + } +} diff --git a/model/channel.go b/model/channel.go index 3da9f4febc..7dee079c52 100644 --- a/model/channel.go +++ b/model/channel.go @@ -57,8 +57,8 @@ func (o *Channel) Etag() string { return Etag(o.Id, o.UpdateAt) } -func (o *Channel) ExtraEtag(memberLimit int) string { - return Etag(o.Id, o.ExtraUpdateAt, memberLimit) +func (o *Channel) StatsEtag() string { + return Etag(o.Id, o.ExtraUpdateAt) } func (o *Channel) IsValid() *AppError { diff --git a/model/channel_extra.go b/model/channel_extra.go deleted file mode 100644 index 55da588af2..0000000000 --- a/model/channel_extra.go +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -package model - -import ( - "encoding/json" - "io" -) - -type ExtraMember struct { - Id string `json:"id"` - Nickname string `json:"nickname"` - Email string `json:"email"` - Roles string `json:"roles"` - Username string `json:"username"` -} - -func (o *ExtraMember) Sanitize(options map[string]bool) { - if len(options) == 0 || !options["email"] { - o.Email = "" - } -} - -type ChannelExtra struct { - Id string `json:"id"` - Members []ExtraMember `json:"members"` - MemberCount int64 `json:"member_count"` -} - -func (o *ChannelExtra) ToJson() string { - b, err := json.Marshal(o) - if err != nil { - return "" - } else { - return string(b) - } -} - -func ChannelExtraFromJson(data io.Reader) *ChannelExtra { - decoder := json.NewDecoder(data) - var o ChannelExtra - err := decoder.Decode(&o) - if err == nil { - return &o - } else { - return nil - } -} diff --git a/model/channel_stats.go b/model/channel_stats.go new file mode 100644 index 0000000000..079769eb02 --- /dev/null +++ b/model/channel_stats.go @@ -0,0 +1,34 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/json" + "io" +) + +type ChannelStats struct { + ChannelId string `json:"channel_id"` + MemberCount int64 `json:"member_count"` +} + +func (o *ChannelStats) ToJson() string { + b, err := json.Marshal(o) + if err != nil { + return "" + } else { + return string(b) + } +} + +func ChannelStatsFromJson(data io.Reader) *ChannelStats { + decoder := json.NewDecoder(data) + var o ChannelStats + err := decoder.Decode(&o) + if err == nil { + return &o + } else { + return nil + } +} diff --git a/model/client.go b/model/client.go index f9a56b86ef..f5aeea4db8 100644 --- a/model/client.go +++ b/model/client.go @@ -131,6 +131,7 @@ func (c *Client) GetFileRoute(fileId string) string { func (c *Client) DoPost(url, data, contentType string) (*http.Response, *AppError) { rq, _ := http.NewRequest("POST", c.Url+url, strings.NewReader(data)) rq.Header.Set("Content-Type", contentType) + rq.Close = true if rp, err := c.HttpClient.Do(rq); err != nil { return nil, NewLocAppError(url, "model.client.connecting.app_error", nil, err.Error()) @@ -144,6 +145,7 @@ func (c *Client) DoPost(url, data, contentType string) (*http.Response, *AppErro func (c *Client) DoApiPost(url string, data string) (*http.Response, *AppError) { rq, _ := http.NewRequest("POST", c.ApiUrl+url, strings.NewReader(data)) + rq.Close = true if len(c.AuthToken) > 0 { rq.Header.Set(HEADER_AUTH, c.AuthType+" "+c.AuthToken) @@ -161,6 +163,7 @@ func (c *Client) DoApiPost(url string, data string) (*http.Response, *AppError) func (c *Client) DoApiGet(url string, data string, etag string) (*http.Response, *AppError) { rq, _ := http.NewRequest("GET", c.ApiUrl+url, strings.NewReader(data)) + rq.Close = true if len(etag) > 0 { rq.Header.Set(HEADER_ETAG_CLIENT, etag) @@ -508,10 +511,9 @@ func (c *Client) GetMe(etag string) (*Result, *AppError) { } } -// GetProfilesForDirectMessageList returns a map of users for a team that can be direct -// messaged, using user id as the key. Must be authenticated. -func (c *Client) GetProfilesForDirectMessageList(teamId string) (*Result, *AppError) { - if r, err := c.DoApiGet("/users/profiles_for_dm_list/"+teamId, "", ""); err != nil { +// GetProfiles returns a map of users using user id as the key. Must be authenticated. +func (c *Client) GetProfiles(offset int, limit int, etag string) (*Result, *AppError) { + if r, err := c.DoApiGet(fmt.Sprintf("/users/%v/%v", offset, limit), "", etag); err != nil { return nil, err } else { defer closeBody(r) @@ -520,10 +522,10 @@ func (c *Client) GetProfilesForDirectMessageList(teamId string) (*Result, *AppEr } } -// GetProfiles returns a map of users for a team using user id as the key. Must +// GetProfilesInTeam returns a map of users for a team using user id as the key. Must // be authenticated. -func (c *Client) GetProfiles(teamId string, etag string) (*Result, *AppError) { - if r, err := c.DoApiGet("/users/profiles/"+teamId, "", etag); err != nil { +func (c *Client) GetProfilesInTeam(teamId string, offset int, limit int, etag string) (*Result, *AppError) { + if r, err := c.DoApiGet(fmt.Sprintf("/teams/%v/users/%v/%v", teamId, offset, limit), "", etag); err != nil { return nil, err } else { defer closeBody(r) @@ -532,10 +534,10 @@ func (c *Client) GetProfiles(teamId string, etag string) (*Result, *AppError) { } } -// GetDirectProfiles gets a map of users that are currently shown in the sidebar, -// using user id as the key. Must be authenticated. -func (c *Client) GetDirectProfiles(etag string) (*Result, *AppError) { - if r, err := c.DoApiGet("/users/direct_profiles", "", etag); err != nil { +// GetProfilesInChannel returns a map of users for a channel using user id as the key. Must +// be authenticated. +func (c *Client) GetProfilesInChannel(channelId string, offset int, limit int, etag string) (*Result, *AppError) { + if r, err := c.DoApiGet(fmt.Sprintf(c.GetChannelRoute(channelId)+"/users/%v/%v", offset, limit), "", etag); err != nil { return nil, err } else { defer closeBody(r) @@ -544,6 +546,72 @@ func (c *Client) GetDirectProfiles(etag string) (*Result, *AppError) { } } +// GetProfilesNotInChannel returns a map of users not in a channel but on the team using user id as the key. Must +// be authenticated. +func (c *Client) GetProfilesNotInChannel(channelId string, offset int, limit int, etag string) (*Result, *AppError) { + if r, err := c.DoApiGet(fmt.Sprintf(c.GetChannelRoute(channelId)+"/users/not_in_channel/%v/%v", offset, limit), "", etag); err != nil { + return nil, err + } else { + defer closeBody(r) + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), UserMapFromJson(r.Body)}, nil + } +} + +// GetProfilesByIds returns a map of users based on the user ids provided. Must +// be authenticated. +func (c *Client) GetProfilesByIds(userIds []string) (*Result, *AppError) { + if r, err := c.DoApiPost("/users/ids", ArrayToJson(userIds)); err != nil { + return nil, err + } else { + defer closeBody(r) + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), UserMapFromJson(r.Body)}, nil + } +} + +// SearchUsers returns a list of users that have a username matching or similar to the search term. Must +// be authenticated. +func (c *Client) SearchUsers(term string, teamId string, options map[string]string) (*Result, *AppError) { + options["term"] = term + options["team_id"] = teamId + if r, err := c.DoApiPost("/users/search", MapToJson(options)); err != nil { + return nil, err + } else { + defer closeBody(r) + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), UserListFromJson(r.Body)}, nil + } +} + +// AutocompleteUsersInChannel returns two lists for autocompletion of users in a channel. The first list "in_channel", +// specifies users in the channel. The second list "out_of_channel" specifies users outside of the +// channel. Term, the string to search against, is required, channel id is also required. Must be authenticated. +func (c *Client) AutocompleteUsersInChannel(term string, channelId string) (*Result, *AppError) { + url := fmt.Sprintf("%s/users/autocomplete?term=%s", c.GetChannelRoute(channelId), url.QueryEscape(term)) + if r, err := c.DoApiGet(url, "", ""); err != nil { + return nil, err + } else { + defer closeBody(r) + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), UserAutocompleteInChannelFromJson(r.Body)}, nil + } +} + +// AutocompleteUsersInTeam returns a list for autocompletion of users in a team. The list "in_team" specifies +// the users in the team that match the provided term, matching against username, full name and +// nickname. Must be authenticated. +func (c *Client) AutocompleteUsersInTeam(term string) (*Result, *AppError) { + url := fmt.Sprintf("%s/users/autocomplete?term=%s", c.GetTeamRoute(), url.QueryEscape(term)) + if r, err := c.DoApiGet(url, "", ""); err != nil { + return nil, err + } else { + defer closeBody(r) + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), UserAutocompleteInTeamFromJson(r.Body)}, nil + } +} + // LoginById authenticates a user by user id and password. func (c *Client) LoginById(id string, password string) (*Result, *AppError) { m := make(map[string]string) @@ -942,6 +1010,7 @@ func (c *Client) SaveComplianceReport(job *Compliance) (*Result, *AppError) { func (c *Client) DownloadComplianceReport(id string) (*Result, *AppError) { var rq *http.Request rq, _ = http.NewRequest("GET", c.ApiUrl+"/admin/download_compliance_report/"+id, nil) + rq.Close = true if len(c.AuthToken) > 0 { rq.Header.Set(HEADER_AUTH, "BEARER "+c.AuthToken) @@ -1174,13 +1243,23 @@ func (c *Client) UpdateLastViewedAt(channelId string, active bool) (*Result, *Ap } } -func (c *Client) GetChannelExtraInfo(id string, memberLimit int, etag string) (*Result, *AppError) { - if r, err := c.DoApiGet(c.GetChannelRoute(id)+"/extra_info/"+strconv.FormatInt(int64(memberLimit), 10), "", etag); err != nil { +func (c *Client) GetChannelStats(id string, etag string) (*Result, *AppError) { + if r, err := c.DoApiGet(c.GetChannelRoute(id)+"/stats", "", etag); err != nil { return nil, err } else { defer closeBody(r) return &Result{r.Header.Get(HEADER_REQUEST_ID), - r.Header.Get(HEADER_ETAG_SERVER), ChannelExtraFromJson(r.Body)}, nil + r.Header.Get(HEADER_ETAG_SERVER), ChannelStatsFromJson(r.Body)}, nil + } +} + +func (c *Client) GetChannelMember(channelId string, userId string) (*Result, *AppError) { + if r, err := c.DoApiGet(c.GetChannelRoute(channelId)+"/members/"+userId, "", ""); err != nil { + return nil, err + } else { + defer closeBody(r) + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), ChannelMemberFromJson(r.Body)}, nil } } @@ -1325,6 +1404,7 @@ func (c *Client) UploadPostAttachment(data []byte, channelId string, filename st func (c *Client) uploadFile(url string, data []byte, contentType string) (*Result, *AppError) { rq, _ := http.NewRequest("POST", url, bytes.NewReader(data)) rq.Header.Set("Content-Type", contentType) + rq.Close = true if len(c.AuthToken) > 0 { rq.Header.Set(HEADER_AUTH, "BEARER "+c.AuthToken) @@ -1525,6 +1605,18 @@ func (c *Client) GetStatuses() (*Result, *AppError) { } } +// GetStatusesByIds returns a map of string statuses using user id as the key, +// based on the provided user ids +func (c *Client) GetStatusesByIds(userIds []string) (*Result, *AppError) { + if r, err := c.DoApiPost("/users/status/ids", ArrayToJson(userIds)); err != nil { + return nil, err + } else { + defer closeBody(r) + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), MapFromJson(r.Body)}, nil + } +} + // SetActiveChannel sets the the channel id the user is currently viewing. // The channelId key is required but the value can be blank. Returns standard // response. @@ -1550,8 +1642,46 @@ func (c *Client) GetMyTeam(etag string) (*Result, *AppError) { } } -func (c *Client) GetTeamMembers(teamId string) (*Result, *AppError) { - if r, err := c.DoApiGet("/teams/members/"+teamId, "", ""); err != nil { +// GetTeamMembers will return a page of team member objects as an array paged based on the +// team id, offset and limit provided. Must be authenticated. +func (c *Client) GetTeamMembers(teamId string, offset int, limit int) (*Result, *AppError) { + if r, err := c.DoApiGet(fmt.Sprintf("/teams/%v/members/%v/%v", teamId, offset, limit), "", ""); err != nil { + return nil, err + } else { + defer closeBody(r) + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), TeamMembersFromJson(r.Body)}, nil + } +} + +// GetTeamMember will return a team member object based on the team id and user id provided. +// Must be authenticated. +func (c *Client) GetTeamMember(teamId string, userId string) (*Result, *AppError) { + if r, err := c.DoApiGet(fmt.Sprintf("/teams/%v/members/%v", teamId, userId), "", ""); err != nil { + return nil, err + } else { + defer closeBody(r) + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), TeamMemberFromJson(r.Body)}, nil + } +} + +// GetTeamStats will return a team stats object containing the number of users on the team +// based on the team id provided. Must be authenticated. +func (c *Client) GetTeamStats(teamId string) (*Result, *AppError) { + if r, err := c.DoApiGet(fmt.Sprintf("/teams/%v/stats", teamId), "", ""); err != nil { + return nil, err + } else { + defer closeBody(r) + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), TeamStatsFromJson(r.Body)}, nil + } +} + +// GetTeamMembersByIds will return team member objects as an array based on the +// team id and a list of user ids provided. Must be authenticated. +func (c *Client) GetTeamMembersByIds(teamId string, userIds []string) (*Result, *AppError) { + if r, err := c.DoApiPost(fmt.Sprintf("/teams/%v/members/ids", teamId), ArrayToJson(userIds)); err != nil { return nil, err } else { defer closeBody(r) @@ -1866,6 +1996,7 @@ func (c *Client) CreateEmoji(emoji *Emoji, image []byte, filename string) (*Emoj rq, _ := http.NewRequest("POST", c.ApiUrl+c.GetEmojiRoute()+"/create", body) rq.Header.Set("Content-Type", writer.FormDataContentType()) + rq.Close = true if len(c.AuthToken) > 0 { rq.Header.Set(HEADER_AUTH, "BEARER "+c.AuthToken) @@ -1908,6 +2039,7 @@ func (c *Client) UploadCertificateFile(data []byte, contentType string) *AppErro url := c.ApiUrl + "/admin/add_certificate" rq, _ := http.NewRequest("POST", url, bytes.NewReader(data)) rq.Header.Set("Content-Type", contentType) + rq.Close = true if len(c.AuthToken) > 0 { rq.Header.Set(HEADER_AUTH, "BEARER "+c.AuthToken) diff --git a/model/initial_load.go b/model/initial_load.go index d7587e6d40..afb0a2762e 100644 --- a/model/initial_load.go +++ b/model/initial_load.go @@ -9,14 +9,13 @@ import ( ) type InitialLoad struct { - User *User `json:"user"` - TeamMembers []*TeamMember `json:"team_members"` - Teams []*Team `json:"teams"` - DirectProfiles map[string]*User `json:"direct_profiles"` - Preferences Preferences `json:"preferences"` - ClientCfg map[string]string `json:"client_cfg"` - LicenseCfg map[string]string `json:"license_cfg"` - NoAccounts bool `json:"no_accounts"` + User *User `json:"user"` + TeamMembers []*TeamMember `json:"team_members"` + Teams []*Team `json:"teams"` + Preferences Preferences `json:"preferences"` + ClientCfg map[string]string `json:"client_cfg"` + LicenseCfg map[string]string `json:"license_cfg"` + NoAccounts bool `json:"no_accounts"` } func (me *InitialLoad) ToJson() string { diff --git a/model/session.go b/model/session.go index c3171ed7c9..a6a753e7f4 100644 --- a/model/session.go +++ b/model/session.go @@ -11,7 +11,7 @@ import ( const ( SESSION_COOKIE_TOKEN = "MMAUTHTOKEN" - SESSION_CACHE_SIZE = 10000 + SESSION_CACHE_SIZE = 25000 SESSION_PROP_PLATFORM = "platform" SESSION_PROP_OS = "os" SESSION_PROP_BROWSER = "browser" diff --git a/model/status.go b/model/status.go index f4ad8e7751..3248664277 100644 --- a/model/status.go +++ b/model/status.go @@ -12,8 +12,9 @@ const ( STATUS_OFFLINE = "offline" STATUS_AWAY = "away" STATUS_ONLINE = "online" - STATUS_CACHE_SIZE = 10000 - STATUS_CHANNEL_TIMEOUT = 20000 // 20 seconds + STATUS_CACHE_SIZE = 25000 + STATUS_CHANNEL_TIMEOUT = 20000 // 20 seconds + STATUS_MIN_UPDATE_TIME = 120000 // 2 minutes ) type Status struct { diff --git a/model/team_stats.go b/model/team_stats.go new file mode 100644 index 0000000000..8634c3d1bf --- /dev/null +++ b/model/team_stats.go @@ -0,0 +1,34 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/json" + "io" +) + +type TeamStats struct { + TeamId string `json:"team_id"` + MemberCount int64 `json:"member_count"` +} + +func (o *TeamStats) ToJson() string { + b, err := json.Marshal(o) + if err != nil { + return "" + } else { + return string(b) + } +} + +func TeamStatsFromJson(data io.Reader) *TeamStats { + decoder := json.NewDecoder(data) + var o TeamStats + err := decoder.Decode(&o) + if err == nil { + return &o + } else { + return nil + } +} diff --git a/model/user.go b/model/user.go index 1b51171ad0..e713444bad 100644 --- a/model/user.go +++ b/model/user.go @@ -413,6 +413,26 @@ func UserMapFromJson(data io.Reader) map[string]*User { } } +func UserListToJson(u []*User) string { + b, err := json.Marshal(u) + if err != nil { + return "" + } else { + return string(b) + } +} + +func UserListFromJson(data io.Reader) []*User { + decoder := json.NewDecoder(data) + var users []*User + err := decoder.Decode(&users) + if err == nil { + return users + } else { + return nil + } +} + // HashPassword generates a hash using the bcrypt.GenerateFromPassword func HashPassword(password string) string { hash, err := bcrypt.GenerateFromPassword([]byte(password), 10) diff --git a/model/utils.go b/model/utils.go index a9c441fee7..4ebd239397 100644 --- a/model/utils.go +++ b/model/utils.go @@ -10,6 +10,7 @@ import ( "encoding/json" "fmt" "io" + "io/ioutil" "net/mail" "net/url" "regexp" @@ -74,13 +75,21 @@ func (er *AppError) ToJson() string { // AppErrorFromJson will decode the input and return an AppError func AppErrorFromJson(data io.Reader) *AppError { - decoder := json.NewDecoder(data) + str := "" + bytes, rerr := ioutil.ReadAll(data) + if rerr != nil { + str = rerr.Error() + } else { + str = string(bytes) + } + + decoder := json.NewDecoder(strings.NewReader(str)) var er AppError err := decoder.Decode(&er) if err == nil { return &er } else { - return NewLocAppError("AppErrorFromJson", "model.utils.decode_json.app_error", nil, err.Error()) + return NewLocAppError("AppErrorFromJson", "model.utils.decode_json.app_error", nil, "body: "+str) } } @@ -166,6 +175,23 @@ func ArrayFromJson(data io.Reader) []string { } } +func ArrayFromInterface(data interface{}) []string { + stringArray := []string{} + + dataArray, ok := data.([]interface{}) + if !ok { + return stringArray + } + + for _, v := range dataArray { + if str, ok := v.(string); ok { + stringArray = append(stringArray, str) + } + } + + return stringArray +} + func StringInterfaceToJson(objmap map[string]interface{}) string { if b, err := json.Marshal(objmap); err != nil { return "" diff --git a/model/utils_test.go b/model/utils_test.go index 5d9289a9ab..dbe1e59bef 100644 --- a/model/utils_test.go +++ b/model/utils_test.go @@ -37,6 +37,13 @@ func TestAppError(t *testing.T) { err.Error() } +func TestAppErrorJunk(t *testing.T) { + rerr := AppErrorFromJson(strings.NewReader("
This is a broken test")) + if "body: This is a broken test" != rerr.DetailedError { + t.Fatal() + } +} + func TestMapJson(t *testing.T) { m := make(map[string]string) diff --git a/model/websocket_client.go b/model/websocket_client.go index a048bd8559..a4983e3852 100644 --- a/model/websocket_client.go +++ b/model/websocket_client.go @@ -17,6 +17,7 @@ type WebSocketClient struct { Sequence int64 // The ever-incrementing sequence attached to each WebSocket action EventChannel chan *WebSocketEvent ResponseChannel chan *WebSocketResponse + ListenError *AppError } // NewWebSocketClient constructs a new WebSocket client with convienence @@ -37,6 +38,7 @@ func NewWebSocketClient(url, authToken string) (*WebSocketClient, *AppError) { 1, make(chan *WebSocketEvent, 100), make(chan *WebSocketResponse, 100), + nil, }, nil } @@ -59,10 +61,20 @@ func (wsc *WebSocketClient) Close() { func (wsc *WebSocketClient) Listen() { go func() { + defer func() { + wsc.Conn.Close() + close(wsc.EventChannel) + close(wsc.ResponseChannel) + }() + for { var rawMsg json.RawMessage var err error if _, rawMsg, err = wsc.Conn.ReadMessage(); err != nil { + if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseNoStatusReceived) { + wsc.ListenError = NewLocAppError("NewWebSocketClient", "model.websocket_client.connect_fail.app_error", nil, err.Error()) + } + return } @@ -107,3 +119,12 @@ func (wsc *WebSocketClient) UserTyping(channelId, parentId string) { func (wsc *WebSocketClient) GetStatuses() { wsc.SendMessage("get_statuses", nil) } + +// GetStatusesByIds will fetch certain user statuses based on ids and return +// a map of string statuses using user id as the key +func (wsc *WebSocketClient) GetStatusesByIds(userIds []string) { + data := map[string]interface{}{ + "user_ids": userIds, + } + wsc.SendMessage("get_statuses_by_ids", data) +} diff --git a/model/websocket_message.go b/model/websocket_message.go index 9bce1e8256..c7ab048574 100644 --- a/model/websocket_message.go +++ b/model/websocket_message.go @@ -31,6 +31,8 @@ const ( type WebSocketMessage interface { ToJson() string IsValid() bool + DoPreComputeJson() + GetPreComputeJson() []byte } type WebsocketBroadcast struct { @@ -41,9 +43,10 @@ type WebsocketBroadcast struct { } type WebSocketEvent struct { - Event string `json:"event"` - Data map[string]interface{} `json:"data"` - Broadcast *WebsocketBroadcast `json:"broadcast"` + Event string `json:"event"` + Data map[string]interface{} `json:"data"` + Broadcast *WebsocketBroadcast `json:"broadcast"` + PreComputeJson []byte `json:"-"` } func (m *WebSocketEvent) Add(key string, value interface{}) { @@ -59,6 +62,19 @@ func (o *WebSocketEvent) IsValid() bool { return o.Event != "" } +func (o *WebSocketEvent) DoPreComputeJson() { + b, err := json.Marshal(o) + if err != nil { + o.PreComputeJson = []byte("") + } else { + o.PreComputeJson = b + } +} + +func (o *WebSocketEvent) GetPreComputeJson() []byte { + return o.PreComputeJson +} + func (o *WebSocketEvent) ToJson() string { b, err := json.Marshal(o) if err != nil { @@ -80,10 +96,11 @@ func WebSocketEventFromJson(data io.Reader) *WebSocketEvent { } type WebSocketResponse struct { - Status string `json:"status"` - SeqReply int64 `json:"seq_reply,omitempty"` - Data map[string]interface{} `json:"data,omitempty"` - Error *AppError `json:"error,omitempty"` + Status string `json:"status"` + SeqReply int64 `json:"seq_reply,omitempty"` + Data map[string]interface{} `json:"data,omitempty"` + Error *AppError `json:"error,omitempty"` + PreComputeJson []byte `json:"-"` } func (m *WebSocketResponse) Add(key string, value interface{}) { @@ -111,6 +128,19 @@ func (o *WebSocketResponse) ToJson() string { } } +func (o *WebSocketResponse) DoPreComputeJson() { + b, err := json.Marshal(o) + if err != nil { + o.PreComputeJson = []byte("") + } else { + o.PreComputeJson = b + } +} + +func (o *WebSocketResponse) GetPreComputeJson() []byte { + return o.PreComputeJson +} + func WebSocketResponseFromJson(data io.Reader) *WebSocketResponse { decoder := json.NewDecoder(data) var o WebSocketResponse diff --git a/store/sql_channel_store.go b/store/sql_channel_store.go index eb150a63cc..a860fea739 100644 --- a/store/sql_channel_store.go +++ b/store/sql_channel_store.go @@ -6,21 +6,26 @@ package store import ( "database/sql" + l4g "github.com/alecthomas/log4go" "github.com/go-gorp/gorp" "github.com/mattermost/platform/model" "github.com/mattermost/platform/utils" ) const ( - MISSING_CHANNEL_ERROR = "store.sql_channel.get_by_name.missing.app_error" - MISSING_CHANNEL_MEMBER_ERROR = "store.sql_channel.get_member.missing.app_error" - CHANNEL_EXISTS_ERROR = "store.sql_channel.save_channel.exists.app_error" + MISSING_CHANNEL_ERROR = "store.sql_channel.get_by_name.missing.app_error" + MISSING_CHANNEL_MEMBER_ERROR = "store.sql_channel.get_member.missing.app_error" + CHANNEL_EXISTS_ERROR = "store.sql_channel.save_channel.exists.app_error" + ALL_CHANNEL_MEMBERS_FOR_USER_CACHE_SIZE = model.SESSION_CACHE_SIZE + ALL_CHANNEL_MEMBERS_FOR_USER_CACHE_SEC = 900 // 15 mins ) type SqlChannelStore struct { *SqlStore } +var allChannelMembersForUserCache *utils.Cache = utils.NewLru(ALL_CHANNEL_MEMBERS_FOR_USER_CACHE_SIZE) + func NewSqlChannelStore(sqlStore *SqlStore) ChannelStore { s := &SqlChannelStore{sqlStore} @@ -517,6 +522,8 @@ func (s SqlChannelStore) SaveMember(member *model.ChannelMember) StoreChannel { } } + s.InvalidateAllChannelMembersForUser(member.UserId) + storeChannel <- result close(storeChannel) }() @@ -619,6 +626,33 @@ func (s SqlChannelStore) GetMember(channelId string, userId string) StoreChannel return storeChannel } +func (us SqlChannelStore) InvalidateAllChannelMembersForUser(userId string) { + allChannelMembersForUserCache.Remove(userId) +} + +func (us SqlChannelStore) IsUserInChannelUseCache(userId string, channelId string) bool { + if cacheItem, ok := allChannelMembersForUserCache.Get(userId); ok { + ids := cacheItem.(map[string]string) + if _, ok := ids[channelId]; ok { + return true + } else { + return false + } + } + + if result := <-us.GetAllChannelMembersForUser(userId, true); result.Err != nil { + l4g.Error("SqlChannelStore.IsUserInChannelUseCache: " + result.Err.Error()) + return false + } else { + ids := result.Data.(map[string]string) + if _, ok := ids[channelId]; ok { + return true + } else { + return false + } + } +} + func (s SqlChannelStore) GetMemberForPost(postId string, userId string) StoreChannel { storeChannel := make(StoreChannel, 1) @@ -649,6 +683,52 @@ func (s SqlChannelStore) GetMemberForPost(postId string, userId string) StoreCha return storeChannel } +type allChannelMember struct { + ChannelId string + Roles string +} + +func (s SqlChannelStore) GetAllChannelMembersForUser(userId string, allowFromCache bool) StoreChannel { + storeChannel := make(StoreChannel, 1) + + go func() { + result := StoreResult{} + + if allowFromCache { + if cacheItem, ok := allChannelMembersForUserCache.Get(userId); ok { + result.Data = cacheItem.(map[string]string) + storeChannel <- result + close(storeChannel) + return + } + } + + var data []allChannelMember + _, err := s.GetReplica().Select(&data, "SELECT ChannelId, Roles FROM Channels, ChannelMembers WHERE Channels.Id = ChannelMembers.ChannelId AND ChannelMembers.UserId = :UserId AND Channels.DeleteAt = 0", map[string]interface{}{"UserId": userId}) + + if err != nil { + result.Err = model.NewLocAppError("SqlChannelStore.GetAllChannelMembersForUser", "store.sql_channel.get_channels.get.app_error", nil, "userId="+userId+", err="+err.Error()) + } else { + + ids := make(map[string]string) + for i := range data { + ids[data[i].ChannelId] = data[i].Roles + } + + result.Data = ids + + if allowFromCache { + allChannelMembersForUserCache.AddWithExpiresInSecs(userId, ids, ALL_CHANNEL_MEMBERS_FOR_USER_CACHE_SEC) + } + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + func (s SqlChannelStore) GetMemberCount(channelId string) StoreChannel { storeChannel := make(StoreChannel, 1) @@ -678,64 +758,6 @@ func (s SqlChannelStore) GetMemberCount(channelId string) StoreChannel { return storeChannel } -func (s SqlChannelStore) GetExtraMembers(channelId string, limit int) StoreChannel { - storeChannel := make(StoreChannel, 1) - - go func() { - result := StoreResult{} - - var members []model.ExtraMember - var err error - - if limit != -1 { - _, err = s.GetReplica().Select(&members, ` - SELECT - Id, - Nickname, - Email, - ChannelMembers.Roles, - Username - FROM - ChannelMembers, - Users - WHERE - ChannelMembers.UserId = Users.Id - AND Users.DeleteAt = 0 - AND ChannelId = :ChannelId - LIMIT :Limit`, map[string]interface{}{"ChannelId": channelId, "Limit": limit}) - } else { - _, err = s.GetReplica().Select(&members, ` - SELECT - Id, - Nickname, - Email, - ChannelMembers.Roles, - Username - FROM - ChannelMembers, - Users - WHERE - ChannelMembers.UserId = Users.Id - AND Users.DeleteAt = 0 - AND ChannelId = :ChannelId`, map[string]interface{}{"ChannelId": channelId}) - } - - if err != nil { - result.Err = model.NewLocAppError("SqlChannelStore.GetExtraMembers", "store.sql_channel.get_extra_members.app_error", nil, "channel_id="+channelId+", "+err.Error()) - } else { - for i := range members { - members[i].Sanitize(utils.Cfg.GetSanitizeOptions()) - } - result.Data = members - } - - storeChannel <- result - close(storeChannel) - }() - - return storeChannel -} - func (s SqlChannelStore) RemoveMember(channelId string, userId string) StoreChannel { storeChannel := make(StoreChannel, 1) diff --git a/store/sql_channel_store_test.go b/store/sql_channel_store_test.go index 19db3d0033..d80d54d526 100644 --- a/store/sql_channel_store_test.go +++ b/store/sql_channel_store_test.go @@ -408,11 +408,6 @@ func TestChannelMemberStore(t *testing.T) { t.Fatal("should have go member") } - extraMembers := (<-store.Channel().GetExtraMembers(o1.ChannelId, 20)).Data.([]model.ExtraMember) - if len(extraMembers) != 1 { - t.Fatal("should have 1 extra members") - } - if err := (<-store.Channel().SaveMember(&o1)).Err; err == nil { t.Fatal("Should have been a duplicate") } @@ -422,18 +417,6 @@ func TestChannelMemberStore(t *testing.T) { if t4 != t3 { t.Fatal("Should not update time upon failure") } - - // rejoin the channel and make sure that an inactive user isn't returned by GetExtraMambers - Must(store.Channel().SaveMember(&o2)) - - u2.DeleteAt = 1000 - Must(store.User().Update(&u2, true)) - - if result := <-store.Channel().GetExtraMembers(o1.ChannelId, 20); result.Err != nil { - t.Fatal(result.Err) - } else if extraMembers := result.Data.([]model.ExtraMember); len(extraMembers) != 1 { - t.Fatal("should have 1 extra members") - } } func TestChannelDeleteMemberStore(t *testing.T) { @@ -534,6 +517,42 @@ func TestChannelStoreGetChannels(t *testing.T) { if list.Channels[0].Id != o1.Id { t.Fatal("missing channel") } + + acresult := <-store.Channel().GetAllChannelMembersForUser(m1.UserId, false) + ids := acresult.Data.(map[string]string) + if _, ok := ids[o1.Id]; !ok { + t.Fatal("missing channel") + } + + acresult2 := <-store.Channel().GetAllChannelMembersForUser(m1.UserId, true) + ids2 := acresult2.Data.(map[string]string) + if _, ok := ids2[o1.Id]; !ok { + t.Fatal("missing channel") + } + + acresult3 := <-store.Channel().GetAllChannelMembersForUser(m1.UserId, true) + ids3 := acresult3.Data.(map[string]string) + if _, ok := ids3[o1.Id]; !ok { + t.Fatal("missing channel") + } + + if !store.Channel().IsUserInChannelUseCache(m1.UserId, o1.Id) { + t.Fatal("missing channel") + } + + if store.Channel().IsUserInChannelUseCache(m1.UserId, o2.Id) { + t.Fatal("missing channel") + } + + if store.Channel().IsUserInChannelUseCache(m1.UserId, "blahblah") { + t.Fatal("missing channel") + } + + if store.Channel().IsUserInChannelUseCache("blahblah", "blahblah") { + t.Fatal("missing channel") + } + + store.Channel().InvalidateAllChannelMembersForUser(m1.UserId) } func TestChannelStoreGetMoreChannels(t *testing.T) { @@ -974,22 +993,10 @@ func TestUpdateExtrasByUser(t *testing.T) { t.Fatal("failed to update extras by user: %v", result.Err) } - if result := <-store.Channel().GetExtraMembers(c1.Id, -1); result.Err != nil { - t.Fatal("failed to get extras: %v", result.Err) - } else if len(result.Data.([]model.ExtraMember)) != 0 { - t.Fatal("got incorrect member count %v", len(result.Data.([]model.ExtraMember))) - } - u1.DeleteAt = 0 Must(store.User().Update(u1, true)) if result := <-store.Channel().ExtraUpdateByUser(u1.Id, u1.DeleteAt); result.Err != nil { t.Fatal("failed to update extras by user: %v", result.Err) } - - if result := <-store.Channel().GetExtraMembers(c1.Id, -1); result.Err != nil { - t.Fatal("failed to get extras: %v", result.Err) - } else if len(result.Data.([]model.ExtraMember)) != 1 { - t.Fatal("got incorrect member count %v", len(result.Data.([]model.ExtraMember))) - } } diff --git a/store/sql_post_store.go b/store/sql_post_store.go index ec8679b314..44ffb556e1 100644 --- a/store/sql_post_store.go +++ b/store/sql_post_store.go @@ -43,6 +43,7 @@ func (s SqlPostStore) CreateIndexesIfNotExists() { s.CreateIndexIfNotExists("idx_posts_create_at", "Posts", "CreateAt") s.CreateIndexIfNotExists("idx_posts_channel_id", "Posts", "ChannelId") s.CreateIndexIfNotExists("idx_posts_root_id", "Posts", "RootId") + s.CreateIndexIfNotExists("idx_posts_user_id", "Posts", "UserId") s.CreateFullTextIndexIfNotExists("idx_posts_message_txt", "Posts", "Message") s.CreateFullTextIndexIfNotExists("idx_posts_hashtags_txt", "Posts", "Hashtags") @@ -811,47 +812,36 @@ func (s SqlPostStore) AnalyticsUserCountsWithPostsByDay(teamId string) StoreChan result := StoreResult{} query := - `SELECT - t1.Name, COUNT(t1.UserId) AS Value - FROM - (SELECT DISTINCT + `SELECT DISTINCT DATE(FROM_UNIXTIME(Posts.CreateAt / 1000)) AS Name, - Posts.UserId - FROM - Posts, Channels - WHERE - Posts.ChannelId = Channels.Id` + COUNT(DISTINCT Posts.UserId) AS Value + FROM Posts + INNER JOIN Channels + ON Posts.ChannelId = Channels.Id` if len(teamId) > 0 { query += " AND Channels.TeamId = :TeamId" } query += ` AND Posts.CreateAt >= :StartTime AND Posts.CreateAt <= :EndTime - ORDER BY Name DESC) AS t1 - GROUP BY Name + GROUP BY DATE(FROM_UNIXTIME(Posts.CreateAt / 1000)) ORDER BY Name DESC LIMIT 30` if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_POSTGRES { query = `SELECT - TO_CHAR(t1.Name, 'YYYY-MM-DD') AS Name, COUNT(t1.UserId) AS Value - FROM - (SELECT DISTINCT - DATE(TO_TIMESTAMP(Posts.CreateAt / 1000)) AS Name, - Posts.UserId - FROM - Posts, Channels - WHERE - Posts.ChannelId = Channels.Id` + TO_CHAR(DATE(TO_TIMESTAMP(Posts.CreateAt / 1000)), 'YYYY-MM-DD') AS Name, COUNT(DISTINCT Posts.UserId) AS Value + FROM Posts + INNER JOIN Channels + ON Posts.ChannelId = Channels.Id` if len(teamId) > 0 { query += " AND Channels.TeamId = :TeamId" } query += ` AND Posts.CreateAt >= :StartTime AND Posts.CreateAt <= :EndTime - ORDER BY Name DESC) AS t1 - GROUP BY Name + GROUP BY DATE(TO_TIMESTAMP(Posts.CreateAt / 1000)) ORDER BY Name DESC LIMIT 30` } @@ -884,15 +874,12 @@ func (s SqlPostStore) AnalyticsPostCountsByDay(teamId string) StoreChannel { result := StoreResult{} query := - `SELECT - Name, COUNT(Value) AS Value - FROM - (SELECT + `SELECT DATE(FROM_UNIXTIME(Posts.CreateAt / 1000)) AS Name, - '1' AS Value - FROM - Posts, Channels - WHERE + COUNT(Posts.Id) AS Value + FROM Posts + INNER JOIN Channels + ON Posts.ChannelId = Channels.Id` if len(teamId) > 0 { @@ -900,31 +887,26 @@ func (s SqlPostStore) AnalyticsPostCountsByDay(teamId string) StoreChannel { } query += ` AND Posts.CreateAt <= :EndTime - AND Posts.CreateAt >= :StartTime) AS t1 - GROUP BY Name + AND Posts.CreateAt >= :StartTime + GROUP BY DATE(FROM_UNIXTIME(Posts.CreateAt / 1000)) ORDER BY Name DESC LIMIT 30` if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_POSTGRES { query = - `SELECT - Name, COUNT(Value) AS Value - FROM - (SELECT - TO_CHAR(DATE(TO_TIMESTAMP(Posts.CreateAt / 1000)), 'YYYY-MM-DD') AS Name, - '1' AS Value - FROM - Posts, Channels - WHERE - Posts.ChannelId = Channels.Id` + `SELECT + TO_CHAR(DATE(TO_TIMESTAMP(Posts.CreateAt / 1000)), 'YYYY-MM-DD') AS Name, Count(Posts.Id) AS Value + FROM Posts + INNER JOIN Channels + ON Posts.ChannelId = Channels.Id` if len(teamId) > 0 { query += " AND Channels.TeamId = :TeamId" } query += ` AND Posts.CreateAt <= :EndTime - AND Posts.CreateAt >= :StartTime) AS t1 - GROUP BY Name + AND Posts.CreateAt >= :StartTime + GROUP BY DATE(TO_TIMESTAMP(Posts.CreateAt / 1000)) ORDER BY Name DESC LIMIT 30` } diff --git a/store/sql_status_store.go b/store/sql_status_store.go index 4d186a30ef..7b9fdea5df 100644 --- a/store/sql_status_store.go +++ b/store/sql_status_store.go @@ -5,6 +5,7 @@ package store import ( "database/sql" + "strconv" "github.com/mattermost/platform/model" ) @@ -43,11 +44,11 @@ func (s SqlStatusStore) SaveOrUpdate(status *model.Status) StoreChannel { if err := s.GetReplica().SelectOne(&model.Status{}, "SELECT * FROM Status WHERE UserId = :UserId", map[string]interface{}{"UserId": status.UserId}); err == nil { if _, err := s.GetMaster().Update(status); err != nil { - result.Err = model.NewLocAppError("SqlStatusStore.SaveOrUpdate", "store.sql_status.update.app_error", nil, "") + result.Err = model.NewLocAppError("SqlStatusStore.SaveOrUpdate", "store.sql_status.update.app_error", nil, err.Error()) } } else { if err := s.GetMaster().Insert(status); err != nil { - result.Err = model.NewLocAppError("SqlStatusStore.SaveOrUpdate", "store.sql_status.save.app_error", nil, "") + result.Err = model.NewLocAppError("SqlStatusStore.SaveOrUpdate", "store.sql_status.save.app_error", nil, err.Error()) } } @@ -89,6 +90,38 @@ func (s SqlStatusStore) Get(userId string) StoreChannel { return storeChannel } +func (s SqlStatusStore) GetByIds(userIds []string) StoreChannel { + storeChannel := make(StoreChannel, 1) + + go func() { + result := StoreResult{} + + props := make(map[string]interface{}) + idQuery := "" + + for index, userId := range userIds { + if len(idQuery) > 0 { + idQuery += ", " + } + + props["userId"+strconv.Itoa(index)] = userId + idQuery += ":userId" + strconv.Itoa(index) + } + + var statuses []*model.Status + if _, err := s.GetReplica().Select(&statuses, "SELECT * FROM Status WHERE UserId IN ("+idQuery+")", props); err != nil { + result.Err = model.NewLocAppError("SqlStatusStore.GetByIds", "store.sql_status.get.app_error", nil, err.Error()) + } else { + result.Data = statuses + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + func (s SqlStatusStore) GetOnlineAway() StoreChannel { storeChannel := make(StoreChannel, 1) @@ -96,7 +129,7 @@ func (s SqlStatusStore) GetOnlineAway() StoreChannel { result := StoreResult{} var statuses []*model.Status - if _, err := s.GetReplica().Select(&statuses, "SELECT * FROM Status WHERE Status = :Online OR Status = :Away", map[string]interface{}{"Online": model.STATUS_ONLINE, "Away": model.STATUS_AWAY}); err != nil { + if _, err := s.GetReplica().Select(&statuses, "SELECT * FROM Status WHERE Status = :Online OR Status = :Away LIMIT 300", map[string]interface{}{"Online": model.STATUS_ONLINE, "Away": model.STATUS_AWAY}); err != nil { result.Err = model.NewLocAppError("SqlStatusStore.GetOnlineAway", "store.sql_status.get_online_away.app_error", nil, err.Error()) } else { result.Data = statuses @@ -157,7 +190,7 @@ func (s SqlStatusStore) ResetAll() StoreChannel { go func() { result := StoreResult{} - if _, err := s.GetMaster().Exec("UPDATE Status SET Status = :Status", map[string]interface{}{"Status": model.STATUS_OFFLINE}); err != nil { + if _, err := s.GetMaster().Exec("UPDATE Status SET Status = :Status WHERE Manual = 0", map[string]interface{}{"Status": model.STATUS_OFFLINE}); err != nil { result.Err = model.NewLocAppError("SqlStatusStore.ResetAll", "store.sql_status.reset_all.app_error", nil, "") } diff --git a/store/sql_status_store_test.go b/store/sql_status_store_test.go index dff4db55e9..dce9738508 100644 --- a/store/sql_status_store_test.go +++ b/store/sql_status_store_test.go @@ -60,6 +60,15 @@ func TestSqlStatusStore(t *testing.T) { } } + if result := <-store.Status().GetByIds([]string{status.UserId, "junk"}); result.Err != nil { + t.Fatal(result.Err) + } else { + statuses := result.Data.([]*model.Status) + if len(statuses) != 1 { + t.Fatal("should only have 1 status") + } + } + if err := (<-store.Status().ResetAll()).Err; err != nil { t.Fatal(err) } diff --git a/store/sql_store.go b/store/sql_store.go index a2bc8f1b8c..1c0de5932d 100644 --- a/store/sql_store.go +++ b/store/sql_store.go @@ -33,6 +33,7 @@ import ( const ( INDEX_TYPE_FULL_TEXT = "full_text" INDEX_TYPE_DEFAULT = "default" + MAX_DB_CONN_LIFETIME = 15 ) const ( @@ -94,9 +95,7 @@ func initConnection() *SqlStore { if len(utils.Cfg.SqlSettings.DataSourceReplicas) == 0 { sqlStore.replicas = make([]*gorp.DbMap, 1) - sqlStore.replicas[0] = setupConnection(fmt.Sprintf("replica-%v", 0), utils.Cfg.SqlSettings.DriverName, utils.Cfg.SqlSettings.DataSource, - utils.Cfg.SqlSettings.MaxIdleConns, utils.Cfg.SqlSettings.MaxOpenConns, - utils.Cfg.SqlSettings.Trace) + sqlStore.replicas[0] = sqlStore.master } else { sqlStore.replicas = make([]*gorp.DbMap, len(utils.Cfg.SqlSettings.DataSourceReplicas)) for i, replica := range utils.Cfg.SqlSettings.DataSourceReplicas { @@ -183,6 +182,7 @@ func setupConnection(con_type string, driver string, dataSource string, maxIdle db.SetMaxIdleConns(maxIdle) db.SetMaxOpenConns(maxOpen) + db.SetConnMaxLifetime(time.Duration(MAX_DB_CONN_LIFETIME) * time.Minute) var dbmap *gorp.DbMap @@ -205,6 +205,26 @@ func setupConnection(con_type string, driver string, dataSource string, maxIdle return dbmap } +func (ss SqlStore) TotalMasterDbConnections() int { + return ss.GetMaster().Db.Stats().OpenConnections +} + +func (ss SqlStore) TotalReadDbConnections() int { + + if len(utils.Cfg.SqlSettings.DataSourceReplicas) == 0 { + return 0 + } else { + count := 0 + for _, db := range ss.replicas { + count = count + db.Db.Stats().OpenConnections + } + + return count + } + + return 0 +} + func (ss SqlStore) GetCurrentSchemaVersion() string { version, _ := ss.GetMaster().SelectStr("SELECT Value FROM Systems WHERE Name='Version'") return version diff --git a/store/sql_team_store.go b/store/sql_team_store.go index 34a4a097da..a69c849040 100644 --- a/store/sql_team_store.go +++ b/store/sql_team_store.go @@ -5,6 +5,7 @@ package store import ( "database/sql" + "strconv" "github.com/mattermost/platform/model" "github.com/mattermost/platform/utils" @@ -441,14 +442,14 @@ func (s SqlTeamStore) GetMember(teamId string, userId string) StoreChannel { return storeChannel } -func (s SqlTeamStore) GetMembers(teamId string) StoreChannel { +func (s SqlTeamStore) GetMembers(teamId string, offset int, limit int) StoreChannel { storeChannel := make(StoreChannel, 1) go func() { result := StoreResult{} var members []*model.TeamMember - _, err := s.GetReplica().Select(&members, "SELECT * FROM TeamMembers WHERE TeamId = :TeamId", map[string]interface{}{"TeamId": teamId}) + _, err := s.GetReplica().Select(&members, "SELECT * FROM TeamMembers WHERE TeamId = :TeamId AND DeleteAt = 0 LIMIT :Limit OFFSET :Offset", map[string]interface{}{"TeamId": teamId, "Offset": offset, "Limit": limit}) if err != nil { result.Err = model.NewLocAppError("SqlTeamStore.GetMembers", "store.sql_team.get_members.app_error", nil, "teamId="+teamId+" "+err.Error()) } else { @@ -462,6 +463,70 @@ func (s SqlTeamStore) GetMembers(teamId string) StoreChannel { return storeChannel } +func (s SqlTeamStore) GetMemberCount(teamId string) StoreChannel { + storeChannel := make(StoreChannel, 1) + + go func() { + result := StoreResult{} + + count, err := s.GetReplica().SelectInt(` + SELECT + count(*) + FROM + TeamMembers, + Users + WHERE + TeamMembers.UserId = Users.Id + AND TeamMembers.TeamId = :TeamId + AND TeamMembers.DeleteAt = 0 + AND Users.DeleteAt = 0`, map[string]interface{}{"TeamId": teamId}) + if err != nil { + result.Err = model.NewLocAppError("SqlTeamStore.GetMemberCount", "store.sql_team.get_member_count.app_error", nil, "teamId="+teamId+" "+err.Error()) + } else { + result.Data = count + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlTeamStore) GetMembersByIds(teamId string, userIds []string) StoreChannel { + storeChannel := make(StoreChannel, 1) + + go func() { + result := StoreResult{} + + var members []*model.TeamMember + props := make(map[string]interface{}) + idQuery := "" + + for index, userId := range userIds { + if len(idQuery) > 0 { + idQuery += ", " + } + + props["userId"+strconv.Itoa(index)] = userId + idQuery += ":userId" + strconv.Itoa(index) + } + + props["TeamId"] = teamId + + if _, err := s.GetReplica().Select(&members, "SELECT * FROM TeamMembers WHERE TeamId = :TeamId AND UserId IN ("+idQuery+") AND DeleteAt = 0", props); err != nil { + result.Err = model.NewLocAppError("SqlTeamStore.GetMembersByIds", "store.sql_team.get_members_by_ids.app_error", nil, "teamId="+teamId+" "+err.Error()) + } else { + result.Data = members + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + func (s SqlTeamStore) GetTeamsForUser(userId string) StoreChannel { storeChannel := make(StoreChannel, 1) diff --git a/store/sql_team_store_test.go b/store/sql_team_store_test.go index be72786d36..46215d9be1 100644 --- a/store/sql_team_store_test.go +++ b/store/sql_team_store_test.go @@ -298,7 +298,7 @@ func TestTeamMembers(t *testing.T) { Must(store.Team().SaveMember(m2)) Must(store.Team().SaveMember(m3)) - if r1 := <-store.Team().GetMembers(teamId1); r1.Err != nil { + if r1 := <-store.Team().GetMembers(teamId1, 0, 100); r1.Err != nil { t.Fatal(r1.Err) } else { ms := r1.Data.([]*model.TeamMember) @@ -308,7 +308,7 @@ func TestTeamMembers(t *testing.T) { } } - if r1 := <-store.Team().GetMembers(teamId2); r1.Err != nil { + if r1 := <-store.Team().GetMembers(teamId2, 0, 100); r1.Err != nil { t.Fatal(r1.Err) } else { ms := r1.Data.([]*model.TeamMember) @@ -342,7 +342,7 @@ func TestTeamMembers(t *testing.T) { t.Fatal(r1.Err) } - if r1 := <-store.Team().GetMembers(teamId1); r1.Err != nil { + if r1 := <-store.Team().GetMembers(teamId1, 0, 100); r1.Err != nil { t.Fatal(r1.Err) } else { ms := r1.Data.([]*model.TeamMember) @@ -363,7 +363,7 @@ func TestTeamMembers(t *testing.T) { t.Fatal(r1.Err) } - if r1 := <-store.Team().GetMembers(teamId1); r1.Err != nil { + if r1 := <-store.Team().GetMembers(teamId1, 0, 100); r1.Err != nil { t.Fatal(r1.Err) } else { ms := r1.Data.([]*model.TeamMember) @@ -434,3 +434,74 @@ func TestGetTeamMember(t *testing.T) { t.Fatal("empty team id - should have failed") } } + +func TestGetTeamMembersByIds(t *testing.T) { + Setup() + + teamId1 := model.NewId() + + m1 := &model.TeamMember{TeamId: teamId1, UserId: model.NewId()} + Must(store.Team().SaveMember(m1)) + + if r := <-store.Team().GetMembersByIds(m1.TeamId, []string{m1.UserId}); r.Err != nil { + t.Fatal(r.Err) + } else { + rm1 := r.Data.([]*model.TeamMember)[0] + + if rm1.TeamId != m1.TeamId { + t.Fatal("bad team id") + } + + if rm1.UserId != m1.UserId { + t.Fatal("bad user id") + } + } + + m2 := &model.TeamMember{TeamId: teamId1, UserId: model.NewId()} + Must(store.Team().SaveMember(m2)) + + if r := <-store.Team().GetMembersByIds(m1.TeamId, []string{m1.UserId, m2.UserId, model.NewId()}); r.Err != nil { + t.Fatal(r.Err) + } else { + rm := r.Data.([]*model.TeamMember) + + if len(rm) != 2 { + t.Fatal("return wrong number of results") + } + } + + if r := <-store.Team().GetMembersByIds(m1.TeamId, []string{}); r.Err == nil { + t.Fatal("empty user ids - should have failed") + } +} + +func TestTeamStoreMemberCount(t *testing.T) { + Setup() + + u1 := &model.User{} + u1.Email = model.NewId() + Must(store.User().Save(u1)) + + teamId1 := model.NewId() + m1 := &model.TeamMember{TeamId: teamId1, UserId: u1.Id} + Must(store.Team().SaveMember(m1)) + + if result := <-store.Team().GetMemberCount(teamId1); result.Err != nil { + t.Fatal(result.Err) + } else { + if result.Data.(int64) != 1 { + t.Fatal("wrong count") + } + } + + m2 := &model.TeamMember{TeamId: teamId1, UserId: model.NewId()} + Must(store.Team().SaveMember(m2)) + + if result := <-store.Team().GetMemberCount(teamId1); result.Err != nil { + t.Fatal(result.Err) + } else { + if result.Data.(int64) != 1 { + t.Fatal("wrong count") + } + } +} diff --git a/store/sql_user_store.go b/store/sql_user_store.go index 8ada9eb2ce..ca86ef1150 100644 --- a/store/sql_user_store.go +++ b/store/sql_user_store.go @@ -15,14 +15,20 @@ import ( ) const ( - MISSING_ACCOUNT_ERROR = "store.sql_user.missing_account.const" - MISSING_AUTH_ACCOUNT_ERROR = "store.sql_user.get_by_auth.missing_account.app_error" + MISSING_ACCOUNT_ERROR = "store.sql_user.missing_account.const" + MISSING_AUTH_ACCOUNT_ERROR = "store.sql_user.get_by_auth.missing_account.app_error" + PROFILES_IN_CHANNEL_CACHE_SIZE = 5000 + PROFILES_IN_CHANNEL_CACHE_SEC = 900 // 15 mins + USER_SEARCH_TYPE_ALL = "Username, FirstName, LastName, Nickname" + USER_SEARCH_TYPE_USERNAME = "Username" ) type SqlUserStore struct { *SqlStore } +var profilesInChannelCache *utils.Cache = utils.NewLru(PROFILES_IN_CHANNEL_CACHE_SIZE) + func NewSqlUserStore(sqlStore *SqlStore) UserStore { us := &SqlUserStore{sqlStore} @@ -49,6 +55,9 @@ func NewSqlUserStore(sqlStore *SqlStore) UserStore { func (us SqlUserStore) CreateIndexesIfNotExists() { us.CreateIndexIfNotExists("idx_users_email", "Users", "Email") + + us.CreateFullTextIndexIfNotExists("idx_users_username_txt", "Users", USER_SEARCH_TYPE_USERNAME) + us.CreateFullTextIndexIfNotExists("idx_users_all_names_txt", "Users", USER_SEARCH_TYPE_ALL) } func (us SqlUserStore) Save(user *model.User) StoreChannel { @@ -457,7 +466,7 @@ func (s SqlUserStore) GetEtagForAllProfiles() StoreChannel { return storeChannel } -func (us SqlUserStore) GetAllProfiles() StoreChannel { +func (us SqlUserStore) GetAllProfiles(offset int, limit int) StoreChannel { storeChannel := make(StoreChannel, 1) @@ -466,8 +475,8 @@ func (us SqlUserStore) GetAllProfiles() StoreChannel { var users []*model.User - if _, err := us.GetReplica().Select(&users, "SELECT * FROM Users"); err != nil { - result.Err = model.NewLocAppError("SqlUserStore.GetProfiles", "store.sql_user.get_profiles.app_error", nil, err.Error()) + if _, err := us.GetReplica().Select(&users, "SELECT * FROM Users ORDER BY Username ASC LIMIT :Limit OFFSET :Offset", map[string]interface{}{"Offset": offset, "Limit": limit}); err != nil { + result.Err = model.NewLocAppError("SqlUserStore.GetAllProfiles", "store.sql_user.get_profiles.app_error", nil, err.Error()) } else { userMap := make(map[string]*model.User) @@ -509,7 +518,7 @@ func (s SqlUserStore) GetEtagForProfiles(teamId string) StoreChannel { return storeChannel } -func (us SqlUserStore) GetProfiles(teamId string) StoreChannel { +func (us SqlUserStore) GetProfiles(teamId string, offset int, limit int) StoreChannel { storeChannel := make(StoreChannel, 1) @@ -518,7 +527,7 @@ func (us SqlUserStore) GetProfiles(teamId string) StoreChannel { var users []*model.User - if _, err := us.GetReplica().Select(&users, "SELECT Users.* FROM Users, TeamMembers WHERE TeamMembers.TeamId = :TeamId AND Users.Id = TeamMembers.UserId", map[string]interface{}{"TeamId": teamId}); err != nil { + if _, err := us.GetReplica().Select(&users, "SELECT Users.* FROM Users, TeamMembers WHERE TeamMembers.TeamId = :TeamId AND Users.Id = TeamMembers.UserId AND TeamMembers.DeleteAt = 0 ORDER BY Users.Username ASC LIMIT :Limit OFFSET :Offset", map[string]interface{}{"TeamId": teamId, "Offset": offset, "Limit": limit}); err != nil { result.Err = model.NewLocAppError("SqlUserStore.GetProfiles", "store.sql_user.get_profiles.app_error", nil, err.Error()) } else { @@ -541,45 +550,36 @@ func (us SqlUserStore) GetProfiles(teamId string) StoreChannel { return storeChannel } -func (us SqlUserStore) GetDirectProfiles(userId string) StoreChannel { +func (us SqlUserStore) InvalidateProfilesInChannelCache(channelId string) { + profilesInChannelCache.Remove(channelId) +} - storeChannel := make(StoreChannel, 1) +func (us SqlUserStore) GetProfilesInChannel(channelId string, offset int, limit int, allowFromCache bool) StoreChannel { + + storeChannel := make(StoreChannel) go func() { result := StoreResult{} + if allowFromCache && offset == -1 && limit == -1 { + if cacheItem, ok := profilesInChannelCache.Get(channelId); ok { + result.Data = cacheItem.(map[string]*model.User) + storeChannel <- result + close(storeChannel) + return + } + } + var users []*model.User - if _, err := us.GetReplica().Select(&users, ` - SELECT - Users.* - FROM - Users - WHERE - Id IN (SELECT DISTINCT - UserId - FROM - ChannelMembers - WHERE - ChannelMembers.UserId != :UserId - AND ChannelMembers.ChannelId IN (SELECT - Channels.Id - FROM - Channels, - ChannelMembers - WHERE - Channels.Type = 'D' - AND Channels.Id = ChannelMembers.ChannelId - AND ChannelMembers.UserId = :UserId)) - OR Id IN (SELECT - Name - FROM - Preferences - WHERE - UserId = :UserId - AND Category = 'direct_channel_show') - `, map[string]interface{}{"UserId": userId}); err != nil { - result.Err = model.NewLocAppError("SqlUserStore.GetDirectProfiles", "store.sql_user.get_profiles.app_error", nil, err.Error()) + query := "SELECT Users.* FROM Users, ChannelMembers WHERE ChannelMembers.ChannelId = :ChannelId AND Users.Id = ChannelMembers.UserId AND Users.DeleteAt = 0" + + if limit >= 0 && offset >= 0 { + query += " ORDER BY Users.Username ASC LIMIT :Limit OFFSET :Offset" + } + + if _, err := us.GetReplica().Select(&users, query, map[string]interface{}{"ChannelId": channelId, "Offset": offset, "Limit": limit}); err != nil { + result.Err = model.NewLocAppError("SqlUserStore.GetProfilesInChannel", "store.sql_user.get_profiles.app_error", nil, err.Error()) } else { userMap := make(map[string]*model.User) @@ -592,6 +592,148 @@ func (us SqlUserStore) GetDirectProfiles(userId string) StoreChannel { } result.Data = userMap + + if allowFromCache && offset == -1 && limit == -1 { + profilesInChannelCache.AddWithExpiresInSecs(channelId, userMap, PROFILES_IN_CHANNEL_CACHE_SEC) + } + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (us SqlUserStore) GetProfilesNotInChannel(teamId string, channelId string, offset int, limit int) StoreChannel { + + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + var users []*model.User + + if _, err := us.GetReplica().Select(&users, ` + SELECT + u.* + FROM Users u + INNER JOIN TeamMembers tm + ON tm.UserId = u.Id + AND tm.TeamId = :TeamId + LEFT JOIN ChannelMembers cm + ON cm.UserId = u.Id + AND cm.ChannelId = :ChannelId + WHERE cm.UserId IS NULL + ORDER BY u.Username ASC + LIMIT :Limit OFFSET :Offset + `, map[string]interface{}{"TeamId": teamId, "ChannelId": channelId, "Offset": offset, "Limit": limit}); err != nil { + result.Err = model.NewLocAppError("SqlUserStore.GetProfilesNotInChannel", "store.sql_user.get_profiles.app_error", nil, err.Error()) + } else { + + userMap := make(map[string]*model.User) + + for _, u := range users { + u.Password = "" + u.AuthData = new(string) + *u.AuthData = "" + userMap[u.Id] = u + } + + result.Data = userMap + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (us SqlUserStore) GetProfilesByUsernames(usernames []string, teamId string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + var users []*model.User + props := make(map[string]interface{}) + idQuery := "" + + for index, usernames := range usernames { + if len(idQuery) > 0 { + idQuery += ", " + } + + props["username"+strconv.Itoa(index)] = usernames + idQuery += ":username" + strconv.Itoa(index) + } + + props["TeamId"] = teamId + + if _, err := us.GetReplica().Select(&users, `SELECT Users.* FROM Users INNER JOIN TeamMembers ON + Users.Id = TeamMembers.UserId AND Users.Username IN (`+idQuery+`) AND TeamMembers.TeamId = :TeamId `, props); err != nil { + result.Err = model.NewLocAppError("SqlUserStore.GetProfilesByUsernames", "store.sql_user.get_profiles.app_error", nil, err.Error()) + } else { + userMap := make(map[string]*model.User) + + for _, u := range users { + u.Password = "" + u.AuthData = new(string) + *u.AuthData = "" + userMap[u.Id] = u + } + + result.Data = userMap + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +type UserWithLastActivityAt struct { + model.User + LastActivityAt int64 +} + +func (us SqlUserStore) GetRecentlyActiveUsersForTeam(teamId string) StoreChannel { + + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + var users []*UserWithLastActivityAt + + if _, err := us.GetReplica().Select(&users, ` + SELECT + u.*, + s.LastActivityAt + FROM Users AS u + INNER JOIN TeamMembers AS t ON u.Id = t.UserId + INNER JOIN Status AS s ON s.UserId = t.UserId + WHERE t.TeamId = :TeamId + ORDER BY s.LastActivityAt DESC + LIMIT 100 + `, map[string]interface{}{"TeamId": teamId}); err != nil { + result.Err = model.NewLocAppError("SqlUserStore.GetRecentlyActiveUsers", "store.sql_user.get_recently_active_users.app_error", nil, err.Error()) + } else { + + userMap := make(map[string]*model.User) + + for _, userWithLastActivityAt := range users { + u := userWithLastActivityAt.User + u.Password = "" + u.AuthData = new(string) + *u.AuthData = "" + u.LastActivityAt = userWithLastActivityAt.LastActivityAt + userMap[u.Id] = &u + } + + result.Data = userMap } storeChannel <- result @@ -938,3 +1080,144 @@ func (us SqlUserStore) GetUnreadCountForChannel(userId string, channelId string) return storeChannel } + +func (us SqlUserStore) Search(teamId string, term string, searchType string) StoreChannel { + storeChannel := make(StoreChannel, 1) + + go func() { + searchQuery := "" + if teamId == "" { + searchQuery = ` + SELECT + * + FROM + Users + WHERE + DeleteAt = 0 + SEARCH_CLAUSE + ORDER BY Username ASC + LIMIT 50` + } else { + searchQuery = ` + SELECT + Users.* + FROM + Users, TeamMembers + WHERE + TeamMembers.TeamId = :TeamId + AND Users.Id = TeamMembers.UserId + AND Users.DeleteAt = 0 + AND TeamMembers.DeleteAt = 0 + SEARCH_CLAUSE + ORDER BY Users.Username ASC + LIMIT 100` + } + + storeChannel <- us.performSearch(searchQuery, term, searchType, map[string]interface{}{"TeamId": teamId}) + close(storeChannel) + + }() + + return storeChannel +} + +func (us SqlUserStore) SearchNotInChannel(teamId string, channelId string, term string, searchType string) StoreChannel { + storeChannel := make(StoreChannel, 1) + + go func() { + searchQuery := "" + if teamId == "" { + searchQuery = ` + SELECT + u.* + FROM Users u + LEFT JOIN ChannelMembers cm + ON cm.UserId = u.Id + AND cm.ChannelId = :ChannelId + WHERE cm.UserId IS NULL + SEARCH_CLAUSE + ORDER BY u.Username ASC + LIMIT 100` + } else { + searchQuery = ` + SELECT + u.* + FROM Users u + INNER JOIN TeamMembers tm + ON tm.UserId = u.Id + AND tm.TeamId = :TeamId + LEFT JOIN ChannelMembers cm + ON cm.UserId = u.Id + AND cm.ChannelId = :ChannelId + WHERE cm.UserId IS NULL + SEARCH_CLAUSE + ORDER BY u.Username ASC + LIMIT 100` + } + + storeChannel <- us.performSearch(searchQuery, term, searchType, map[string]interface{}{"TeamId": teamId, "ChannelId": channelId}) + close(storeChannel) + + }() + + return storeChannel +} + +func (us SqlUserStore) SearchInChannel(channelId string, term string, searchType string) StoreChannel { + storeChannel := make(StoreChannel, 1) + + go func() { + searchQuery := ` + SELECT + Users.* + FROM + Users, ChannelMembers + WHERE + ChannelMembers.ChannelId = :ChannelId + AND ChannelMembers.UserId = Users.Id + AND Users.DeleteAt = 0 + SEARCH_CLAUSE + ORDER BY Users.Username ASC + LIMIT 100` + + storeChannel <- us.performSearch(searchQuery, term, searchType, map[string]interface{}{"ChannelId": channelId}) + close(storeChannel) + + }() + + return storeChannel +} + +func (us SqlUserStore) performSearch(searchQuery string, term string, searchType string, parameters map[string]interface{}) StoreResult { + result := StoreResult{} + + if term == "" { + searchQuery = strings.Replace(searchQuery, "SEARCH_CLAUSE", "", 1) + } else if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_POSTGRES { + term = term + ":*" + searchClause := fmt.Sprintf("AND (%s) @@ to_tsquery(:Term)", searchType) + searchQuery = strings.Replace(searchQuery, "SEARCH_CLAUSE", searchClause, 1) + } else if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_MYSQL { + term = term + "*" + searchClause := fmt.Sprintf("AND MATCH(%s) AGAINST (:Term IN BOOLEAN MODE)", searchType) + searchQuery = strings.Replace(searchQuery, "SEARCH_CLAUSE", searchClause, 1) + } + + var users []*model.User + + parameters["Term"] = term + + if _, err := us.GetReplica().Select(&users, searchQuery, parameters); err != nil { + result.Err = model.NewLocAppError("SqlUserStore.Search", "store.sql_user.search.app_error", nil, "term="+term+", "+"search_type="+searchType+", "+err.Error()) + } else { + for _, u := range users { + u.Password = "" + u.AuthData = new(string) + *u.AuthData = "" + } + + result.Data = users + } + + return result +} diff --git a/store/sql_user_store_test.go b/store/sql_user_store_test.go index 076be1a811..7ffb68a476 100644 --- a/store/sql_user_store_test.go +++ b/store/sql_user_store_test.go @@ -205,7 +205,7 @@ func TestUserStoreGetAllProfiles(t *testing.T) { Must(store.User().Save(u2)) Must(store.Team().SaveMember(&model.TeamMember{TeamId: teamId, UserId: u2.Id})) - if r1 := <-store.User().GetAllProfiles(); r1.Err != nil { + if r1 := <-store.User().GetAllProfiles(0, 100); r1.Err != nil { t.Fatal(r1.Err) } else { users := r1.Data.(map[string]*model.User) @@ -213,6 +213,15 @@ func TestUserStoreGetAllProfiles(t *testing.T) { t.Fatal("invalid returned users") } } + + if r2 := <-store.User().GetAllProfiles(0, 1); r2.Err != nil { + t.Fatal(r2.Err) + } else { + users := r2.Data.(map[string]*model.User) + if len(users) != 1 { + t.Fatal("invalid returned users, limit did not work") + } + } } func TestUserStoreGetProfiles(t *testing.T) { @@ -230,7 +239,7 @@ func TestUserStoreGetProfiles(t *testing.T) { Must(store.User().Save(u2)) Must(store.Team().SaveMember(&model.TeamMember{TeamId: teamId, UserId: u2.Id})) - if r1 := <-store.User().GetProfiles(teamId); r1.Err != nil { + if r1 := <-store.User().GetProfiles(teamId, 0, 100); r1.Err != nil { t.Fatal(r1.Err) } else { users := r1.Data.(map[string]*model.User) @@ -243,7 +252,7 @@ func TestUserStoreGetProfiles(t *testing.T) { } } - if r2 := <-store.User().GetProfiles("123"); r2.Err != nil { + if r2 := <-store.User().GetProfiles("123", 0, 100); r2.Err != nil { t.Fatal(r2.Err) } else { if len(r2.Data.(map[string]*model.User)) != 0 { @@ -252,7 +261,7 @@ func TestUserStoreGetProfiles(t *testing.T) { } } -func TestUserStoreGetDirectProfiles(t *testing.T) { +func TestUserStoreGetProfilesInChannel(t *testing.T) { Setup() teamId := model.NewId() @@ -267,7 +276,151 @@ func TestUserStoreGetDirectProfiles(t *testing.T) { Must(store.User().Save(u2)) Must(store.Team().SaveMember(&model.TeamMember{TeamId: teamId, UserId: u2.Id})) - if r1 := <-store.User().GetDirectProfiles(u1.Id); r1.Err != nil { + c1 := model.Channel{} + c1.TeamId = teamId + c1.DisplayName = "Profiles in channel" + c1.Name = "profiles-" + model.NewId() + c1.Type = model.CHANNEL_OPEN + + c2 := model.Channel{} + c2.TeamId = teamId + c2.DisplayName = "Profiles in private" + c2.Name = "profiles-" + model.NewId() + c2.Type = model.CHANNEL_PRIVATE + + Must(store.Channel().Save(&c1)) + Must(store.Channel().Save(&c2)) + + m1 := model.ChannelMember{} + m1.ChannelId = c1.Id + m1.UserId = u1.Id + m1.NotifyProps = model.GetDefaultChannelNotifyProps() + + m2 := model.ChannelMember{} + m2.ChannelId = c1.Id + m2.UserId = u2.Id + m2.NotifyProps = model.GetDefaultChannelNotifyProps() + + m3 := model.ChannelMember{} + m3.ChannelId = c2.Id + m3.UserId = u1.Id + m3.NotifyProps = model.GetDefaultChannelNotifyProps() + + Must(store.Channel().SaveMember(&m1)) + Must(store.Channel().SaveMember(&m2)) + Must(store.Channel().SaveMember(&m3)) + + if r1 := <-store.User().GetProfilesInChannel(c1.Id, -1, -1, false); r1.Err != nil { + t.Fatal(r1.Err) + } else { + users := r1.Data.(map[string]*model.User) + if len(users) != 2 { + t.Fatal("invalid returned users") + } + + if users[u1.Id].Id != u1.Id { + t.Fatal("invalid returned user") + } + } + + if r2 := <-store.User().GetProfilesInChannel(c2.Id, -1, -1, false); r2.Err != nil { + t.Fatal(r2.Err) + } else { + if len(r2.Data.(map[string]*model.User)) != 1 { + t.Fatal("should have returned empty map") + } + } + + if r2 := <-store.User().GetProfilesInChannel(c2.Id, -1, -1, true); r2.Err != nil { + t.Fatal(r2.Err) + } else { + if len(r2.Data.(map[string]*model.User)) != 1 { + t.Fatal("should have returned empty map") + } + } + + if r2 := <-store.User().GetProfilesInChannel(c2.Id, -1, -1, true); r2.Err != nil { + t.Fatal(r2.Err) + } else { + if len(r2.Data.(map[string]*model.User)) != 1 { + t.Fatal("should have returned empty map") + } + } + + store.User().InvalidateProfilesInChannelCache(c2.Id) +} + +func TestUserStoreGetProfilesNotInChannel(t *testing.T) { + Setup() + + teamId := model.NewId() + + u1 := &model.User{} + u1.Email = model.NewId() + Must(store.User().Save(u1)) + Must(store.Team().SaveMember(&model.TeamMember{TeamId: teamId, UserId: u1.Id})) + + u2 := &model.User{} + u2.Email = model.NewId() + Must(store.User().Save(u2)) + Must(store.Team().SaveMember(&model.TeamMember{TeamId: teamId, UserId: u2.Id})) + + c1 := model.Channel{} + c1.TeamId = teamId + c1.DisplayName = "Profiles in channel" + c1.Name = "profiles-" + model.NewId() + c1.Type = model.CHANNEL_OPEN + + c2 := model.Channel{} + c2.TeamId = teamId + c2.DisplayName = "Profiles in private" + c2.Name = "profiles-" + model.NewId() + c2.Type = model.CHANNEL_PRIVATE + + Must(store.Channel().Save(&c1)) + Must(store.Channel().Save(&c2)) + + if r1 := <-store.User().GetProfilesNotInChannel(teamId, c1.Id, 0, 100); r1.Err != nil { + t.Fatal(r1.Err) + } else { + users := r1.Data.(map[string]*model.User) + if len(users) != 2 { + t.Fatal("invalid returned users") + } + + if users[u1.Id].Id != u1.Id { + t.Fatal("invalid returned user") + } + } + + if r2 := <-store.User().GetProfilesNotInChannel(teamId, c2.Id, 0, 100); r2.Err != nil { + t.Fatal(r2.Err) + } else { + if len(r2.Data.(map[string]*model.User)) != 2 { + t.Fatal("invalid returned users") + } + } + + m1 := model.ChannelMember{} + m1.ChannelId = c1.Id + m1.UserId = u1.Id + m1.NotifyProps = model.GetDefaultChannelNotifyProps() + + m2 := model.ChannelMember{} + m2.ChannelId = c1.Id + m2.UserId = u2.Id + m2.NotifyProps = model.GetDefaultChannelNotifyProps() + + m3 := model.ChannelMember{} + m3.ChannelId = c2.Id + m3.UserId = u1.Id + m3.NotifyProps = model.GetDefaultChannelNotifyProps() + + Must(store.Channel().SaveMember(&m1)) + Must(store.Channel().SaveMember(&m2)) + Must(store.Channel().SaveMember(&m3)) + + if r1 := <-store.User().GetProfilesNotInChannel(teamId, c1.Id, 0, 100); r1.Err != nil { t.Fatal(r1.Err) } else { users := r1.Data.(map[string]*model.User) @@ -276,11 +429,11 @@ func TestUserStoreGetDirectProfiles(t *testing.T) { } } - if r2 := <-store.User().GetDirectProfiles("123"); r2.Err != nil { + if r2 := <-store.User().GetProfilesNotInChannel(teamId, c2.Id, 0, 100); r2.Err != nil { t.Fatal(r2.Err) } else { - if len(r2.Data.(map[string]*model.User)) != 0 { - t.Fatal("should have returned empty map") + if len(r2.Data.(map[string]*model.User)) != 1 { + t.Fatal("should have had 1 user not in channel") } } } @@ -326,7 +479,7 @@ func TestUserStoreGetProfilesByIds(t *testing.T) { } } - if r2 := <-store.User().GetProfiles("123"); r2.Err != nil { + if r2 := <-store.User().GetProfiles("123", 0, 100); r2.Err != nil { t.Fatal(r2.Err) } else { if len(r2.Data.(map[string]*model.User)) != 0 { @@ -335,6 +488,50 @@ func TestUserStoreGetProfilesByIds(t *testing.T) { } } +func TestUserStoreGetProfilesByUsernames(t *testing.T) { + Setup() + + teamId := model.NewId() + + u1 := &model.User{} + u1.Email = model.NewId() + u1.Username = "username1" + model.NewId() + Must(store.User().Save(u1)) + Must(store.Team().SaveMember(&model.TeamMember{TeamId: teamId, UserId: u1.Id})) + + u2 := &model.User{} + u2.Email = model.NewId() + u2.Username = "username2" + model.NewId() + Must(store.User().Save(u2)) + Must(store.Team().SaveMember(&model.TeamMember{TeamId: teamId, UserId: u2.Id})) + + if r1 := <-store.User().GetProfilesByUsernames([]string{u1.Username, u2.Username}, teamId); r1.Err != nil { + t.Fatal(r1.Err) + } else { + users := r1.Data.(map[string]*model.User) + if len(users) != 2 { + t.Fatal("invalid returned users") + } + + if users[u1.Id].Id != u1.Id { + t.Fatal("invalid returned user") + } + } + + if r1 := <-store.User().GetProfilesByUsernames([]string{u1.Username}, teamId); r1.Err != nil { + t.Fatal(r1.Err) + } else { + users := r1.Data.(map[string]*model.User) + if len(users) != 1 { + t.Fatal("invalid returned users") + } + + if users[u1.Id].Id != u1.Id { + t.Fatal("invalid returned user") + } + } +} + func TestUserStoreGetSystemAdminProfiles(t *testing.T) { Setup() @@ -713,3 +910,216 @@ func TestUserStoreUpdateMfaActive(t *testing.T) { t.Fatal(err) } } + +func TestUserStoreGetRecentlyActiveUsersForTeam(t *testing.T) { + Setup() + + u1 := &model.User{} + u1.Email = model.NewId() + Must(store.User().Save(u1)) + Must(store.Status().SaveOrUpdate(&model.Status{u1.Id, model.STATUS_ONLINE, false, model.GetMillis(), ""})) + tid := model.NewId() + Must(store.Team().SaveMember(&model.TeamMember{TeamId: tid, UserId: u1.Id})) + + if r1 := <-store.User().GetRecentlyActiveUsersForTeam(tid); r1.Err != nil { + t.Fatal(r1.Err) + } +} + +func TestUserStoreSearch(t *testing.T) { + Setup() + + u1 := &model.User{} + u1.Username = "jimbo" + model.NewId() + u1.FirstName = "Tim" + u1.LastName = "Bill" + u1.Nickname = "Rob" + u1.Email = model.NewId() + Must(store.User().Save(u1)) + + tid := model.NewId() + Must(store.Team().SaveMember(&model.TeamMember{TeamId: tid, UserId: u1.Id})) + + if r1 := <-store.User().Search(tid, "jimb", USER_SEARCH_TYPE_USERNAME); r1.Err != nil { + t.Fatal(r1.Err) + } else { + profiles := r1.Data.([]*model.User) + found := false + for _, profile := range profiles { + if profile.Id == u1.Id { + found = true + break + } + } + + if !found { + t.Fatal("should have found user") + } + } + + if r1 := <-store.User().Search("", "jimb", USER_SEARCH_TYPE_USERNAME); r1.Err != nil { + t.Fatal(r1.Err) + } else { + profiles := r1.Data.([]*model.User) + found := false + for _, profile := range profiles { + if profile.Id == u1.Id { + found = true + break + } + } + + if !found { + t.Fatal("should have found user") + } + } + + if r1 := <-store.User().Search(tid, "", USER_SEARCH_TYPE_USERNAME); r1.Err != nil { + t.Fatal(r1.Err) + } + + c1 := model.Channel{} + c1.TeamId = tid + c1.DisplayName = "NameName" + c1.Name = "a" + model.NewId() + "b" + c1.Type = model.CHANNEL_OPEN + c1 = *Must(store.Channel().Save(&c1)).(*model.Channel) + + if r1 := <-store.User().SearchNotInChannel(tid, c1.Id, "jimb", USER_SEARCH_TYPE_USERNAME); r1.Err != nil { + t.Fatal(r1.Err) + } else { + profiles := r1.Data.([]*model.User) + found := false + for _, profile := range profiles { + if profile.Id == u1.Id { + found = true + break + } + } + + if !found { + t.Fatal("should have found user") + } + } + + if r1 := <-store.User().SearchNotInChannel("", c1.Id, "jimb", USER_SEARCH_TYPE_USERNAME); r1.Err != nil { + t.Fatal(r1.Err) + } else { + profiles := r1.Data.([]*model.User) + found := false + for _, profile := range profiles { + if profile.Id == u1.Id { + found = true + break + } + } + + if !found { + t.Fatal("should have found user") + } + } + + if r1 := <-store.User().SearchNotInChannel("junk", c1.Id, "jimb", USER_SEARCH_TYPE_USERNAME); r1.Err != nil { + t.Fatal(r1.Err) + } else { + profiles := r1.Data.([]*model.User) + found := false + for _, profile := range profiles { + if profile.Id == u1.Id { + found = true + break + } + } + + if found { + t.Fatal("should not have found user") + } + } + + if r1 := <-store.User().SearchInChannel(c1.Id, "jimb", USER_SEARCH_TYPE_USERNAME); r1.Err != nil { + t.Fatal(r1.Err) + } else { + profiles := r1.Data.([]*model.User) + found := false + for _, profile := range profiles { + if profile.Id == u1.Id { + found = true + break + } + } + + if found { + t.Fatal("should not have found user") + } + } + + Must(store.Channel().SaveMember(&model.ChannelMember{ChannelId: c1.Id, UserId: u1.Id, NotifyProps: model.GetDefaultChannelNotifyProps()})) + + if r1 := <-store.User().SearchInChannel(c1.Id, "jimb", USER_SEARCH_TYPE_USERNAME); r1.Err != nil { + t.Fatal(r1.Err) + } else { + profiles := r1.Data.([]*model.User) + found := false + for _, profile := range profiles { + if profile.Id == u1.Id { + found = true + break + } + } + + if !found { + t.Fatal("should have found user") + } + } + + if r1 := <-store.User().Search(tid, "Tim", USER_SEARCH_TYPE_ALL); r1.Err != nil { + t.Fatal(r1.Err) + } else { + profiles := r1.Data.([]*model.User) + found := false + for _, profile := range profiles { + if profile.Id == u1.Id { + found = true + break + } + } + + if !found { + t.Fatal("should have found user") + } + } + + if r1 := <-store.User().Search(tid, "Bill", USER_SEARCH_TYPE_ALL); r1.Err != nil { + t.Fatal(r1.Err) + } else { + profiles := r1.Data.([]*model.User) + found := false + for _, profile := range profiles { + if profile.Id == u1.Id { + found = true + break + } + } + + if !found { + t.Fatal("should have found user") + } + } + + if r1 := <-store.User().Search(tid, "Rob", USER_SEARCH_TYPE_ALL); r1.Err != nil { + t.Fatal(r1.Err) + } else { + profiles := r1.Data.([]*model.User) + found := false + for _, profile := range profiles { + if profile.Id == u1.Id { + found = true + break + } + } + + if !found { + t.Fatal("should have found user") + } + } +} diff --git a/store/store.go b/store/store.go index 7474d3afb1..900709f16c 100644 --- a/store/store.go +++ b/store/store.go @@ -49,6 +49,8 @@ type Store interface { MarkSystemRanUnitTests() Close() DropAllTables() + TotalMasterDbConnections() int + TotalReadDbConnections() int } type TeamStore interface { @@ -66,7 +68,9 @@ type TeamStore interface { SaveMember(member *model.TeamMember) StoreChannel UpdateMember(member *model.TeamMember) StoreChannel GetMember(teamId string, userId string) StoreChannel - GetMembers(teamId string) StoreChannel + GetMembers(teamId string, offset int, limit int) StoreChannel + GetMembersByIds(teamId string, userIds []string) StoreChannel + GetMemberCount(teamId string) StoreChannel GetTeamsForUser(userId string) StoreChannel RemoveMember(teamId string, userId string) StoreChannel RemoveAllMembersByTeam(teamId string) StoreChannel @@ -89,16 +93,17 @@ type ChannelStore interface { GetChannelCounts(teamId string, userId string) StoreChannel GetAll(teamId string) StoreChannel GetForPost(postId string) StoreChannel - SaveMember(member *model.ChannelMember) StoreChannel UpdateMember(member *model.ChannelMember) StoreChannel GetMembers(channelId string) StoreChannel GetMember(channelId string, userId string) StoreChannel + GetAllChannelMembersForUser(userId string, allowFromCache bool) StoreChannel + InvalidateAllChannelMembersForUser(userId string) + IsUserInChannelUseCache(userId string, channelId string) bool GetMemberForPost(postId string, userId string) StoreChannel GetMemberCount(channelId string) StoreChannel RemoveMember(channelId string, userId string) StoreChannel PermanentDeleteMembersByUser(userId string) StoreChannel - GetExtraMembers(channelId string, limit int) StoreChannel UpdateLastViewedAt(channelId string, userId string) StoreChannel SetLastViewedAt(channelId string, userId string, newLastViewedAt int64) StoreChannel IncrementMentionCount(channelId string, userId string) StoreChannel @@ -135,9 +140,12 @@ type UserStore interface { UpdateMfaActive(userId string, active bool) StoreChannel Get(id string) StoreChannel GetAll() StoreChannel - GetAllProfiles() StoreChannel - GetProfiles(teamId string) StoreChannel - GetDirectProfiles(userId string) StoreChannel + InvalidateProfilesInChannelCache(channelId string) + GetProfilesInChannel(channelId string, offset int, limit int, allowFromCache bool) StoreChannel + GetProfilesNotInChannel(teamId string, channelId string, offset int, limit int) StoreChannel + GetProfilesByUsernames(usernames []string, teamId string) StoreChannel + GetAllProfiles(offset int, limit int) StoreChannel + GetProfiles(teamId string, offset int, limit int) StoreChannel GetProfileByIds(userId []string) StoreChannel GetByEmail(email string) StoreChannel GetByAuth(authData *string, authService string) StoreChannel @@ -147,7 +155,6 @@ type UserStore interface { VerifyEmail(userId string) StoreChannel GetEtagForAllProfiles() StoreChannel GetEtagForProfiles(teamId string) StoreChannel - GetEtagForDirectProfiles(userId string) StoreChannel UpdateFailedPasswordAttempts(userId string, attempts int) StoreChannel GetTotalUsersCount() StoreChannel GetSystemAdminProfiles() StoreChannel @@ -155,6 +162,10 @@ type UserStore interface { AnalyticsUniqueUserCount(teamId string) StoreChannel GetUnreadCount(userId string) StoreChannel GetUnreadCountForChannel(userId string, channelId string) StoreChannel + GetRecentlyActiveUsersForTeam(teamId string) StoreChannel + Search(teamId string, term string, searchType string) StoreChannel + SearchInChannel(channelId string, term string, searchType string) StoreChannel + SearchNotInChannel(teamId string, channelId string, term string, searchType string) StoreChannel } type SessionStore interface { @@ -274,6 +285,7 @@ type EmojiStore interface { type StatusStore interface { SaveOrUpdate(status *model.Status) StoreChannel Get(userId string) StoreChannel + GetByIds(userIds []string) StoreChannel GetOnlineAway() StoreChannel GetOnline() StoreChannel GetAllFromTeam(teamId string) StoreChannel diff --git a/web/web_test.go b/web/web_test.go index 21819191b9..66969930f9 100644 --- a/web/web_test.go +++ b/web/web_test.go @@ -23,7 +23,7 @@ func Setup() { utils.TranslationsPreInit() utils.LoadConfig("config.json") utils.InitTranslations(utils.Cfg.LocalizationSettings) - api.NewServer() + api.NewServer(false) api.StartServer() api.InitApi() InitWeb() @@ -161,7 +161,7 @@ func TestGetAccessToken(t *testing.T) { } } - if result, err := ApiClient.DoApiGet("/users/profiles/"+teamId+"?access_token="+token, "", ""); err != nil { + if result, err := ApiClient.DoApiGet("/teams/"+teamId+"/users/0/100?access_token="+token, "", ""); err != nil { t.Fatal(err) } else { userMap := model.UserMapFromJson(result.Body) @@ -170,16 +170,16 @@ func TestGetAccessToken(t *testing.T) { } } - if _, err := ApiClient.DoApiGet("/users/profiles/"+teamId, "", ""); err == nil { + if _, err := ApiClient.DoApiGet("/teams/"+teamId+"/users/0/100", "", ""); err == nil { t.Fatal("should have failed - no access token provided") } - if _, err := ApiClient.DoApiGet("/users/profiles/"+teamId+"?access_token=junk", "", ""); err == nil { + if _, err := ApiClient.DoApiGet("/teams/"+teamId+"/users/0/100?access_token=junk", "", ""); err == nil { t.Fatal("should have failed - bad access token provided") } ApiClient.SetOAuthToken(token) - if result, err := ApiClient.DoApiGet("/users/profiles/"+teamId, "", ""); err != nil { + if result, err := ApiClient.DoApiGet("/teams/"+teamId+"/users/0/100", "", ""); err != nil { t.Fatal(err) } else { userMap := model.UserMapFromJson(result.Body) diff --git a/webapp/actions/channel_actions.jsx b/webapp/actions/channel_actions.jsx index ed8e00db64..8364fe9b61 100644 --- a/webapp/actions/channel_actions.jsx +++ b/webapp/actions/channel_actions.jsx @@ -1,18 +1,26 @@ // Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import {browserHistory} from 'react-router/es6'; -import * as Utils from 'utils/utils.jsx'; +import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; + import TeamStore from 'stores/team_store.jsx'; import UserStore from 'stores/user_store.jsx'; import ChannelStore from 'stores/channel_store.jsx'; +import PreferenceStore from 'stores/preference_store.jsx'; + +import {loadProfilesAndTeamMembersForDMSidebar} from 'actions/user_actions.jsx'; + +import Client from 'client/web_client.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; import * as UserAgent from 'utils/user_agent.jsx'; -import Client from 'client/web_client.jsx'; +import * as Utils from 'utils/utils.jsx'; +import {Preferences, ActionTypes} from 'utils/constants.jsx'; + +import {browserHistory} from 'react-router/es6'; export function goToChannel(channel) { if (channel.fake) { - Utils.openDirectChannelToUser( + openDirectChannelToUser( UserStore.getProfileByUsername(channel.display_name), () => { browserHistory.push(TeamStore.getCurrentTeamRelativeUrl() + '/channels/' + channel.name); @@ -53,3 +61,124 @@ export function setChannelAsRead(channelIdParam) { ChannelStore.emitLastViewed(Number.MAX_VALUE, false); } } + +export function addUserToChannel(channelId, userId, success, error) { + Client.addChannelMember( + channelId, + userId, + (data) => { + UserStore.removeProfileNotInChannel(channelId, userId); + const profile = UserStore.getProfile(userId); + if (profile) { + UserStore.saveProfileInChannel(channelId, profile); + UserStore.emitInChannelChange(); + } + UserStore.emitNotInChannelChange(); + + if (success) { + success(data); + } + }, + (err) => { + AsyncClient.dispatchError(err, 'addChannelMember'); + + if (error) { + error(err); + } + } + ); +} + +export function removeUserFromChannel(channelId, userId, success, error) { + Client.removeChannelMember( + channelId, + userId, + (data) => { + UserStore.removeProfileInChannel(channelId, userId); + const profile = UserStore.getProfile(userId); + if (profile) { + UserStore.saveProfileNotInChannel(channelId, profile); + UserStore.emitNotInChannelChange(); + } + UserStore.emitInChannelChange(); + + if (success) { + success(data); + } + }, + (err) => { + AsyncClient.dispatchError(err, 'removeChannelMember'); + + if (error) { + error(err); + } + } + ); +} + +export function openDirectChannelToUser(user, success, error) { + const channelName = Utils.getDirectChannelName(UserStore.getCurrentId(), user.id); + let channel = ChannelStore.getByName(channelName); + + if (channel) { + PreferenceStore.setPreference(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, user.id, 'true'); + loadProfilesAndTeamMembersForDMSidebar(); + + AsyncClient.savePreference( + Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, + user.id, + 'true' + ); + + if (success) { + success(channel, true); + } + + return; + } + + channel = { + name: channelName, + last_post_at: 0, + total_msg_count: 0, + type: 'D', + display_name: user.username, + teammate_id: user.id, + status: UserStore.getStatus(user.id) + }; + + Client.createDirectChannel( + user.id, + (data) => { + Client.getChannel( + data.id, + (data2) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_CHANNEL, + channel: data2.channel, + member: data2.member + }); + + PreferenceStore.setPreference(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, user.id, 'true'); + loadProfilesAndTeamMembersForDMSidebar(); + + AsyncClient.savePreference( + Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, + user.id, + 'true' + ); + + if (success) { + success(data2.channel, false); + } + } + ); + }, + () => { + browserHistory.push(TeamStore.getCurrentTeamUrl() + '/channels/' + channelName); + if (error) { + error(); + } + } + ); +} diff --git a/webapp/actions/emoji_actions.jsx b/webapp/actions/emoji_actions.jsx new file mode 100644 index 0000000000..128a9325a1 --- /dev/null +++ b/webapp/actions/emoji_actions.jsx @@ -0,0 +1,46 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; + +import UserStore from 'stores/user_store.jsx'; + +import * as AsyncClient from 'utils/async_client.jsx'; +import Client from 'client/web_client.jsx'; + +import {ActionTypes} from 'utils/constants.jsx'; + +export function loadEmoji(getProfiles = true) { + Client.listEmoji( + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_CUSTOM_EMOJIS, + emojis: data + }); + + if (getProfiles) { + loadProfilesForEmoji(data); + } + }, + (err) => { + AsyncClient.dispatchError(err, 'listEmoji'); + } + ); +} + +function loadProfilesForEmoji(emojiList) { + const profilesToLoad = {}; + for (let i = 0; i < emojiList.length; i++) { + const emoji = emojiList[i]; + if (!UserStore.hasProfile(emoji.creator_id)) { + profilesToLoad[emoji.creator_id] = true; + } + } + + const list = Object.keys(profilesToLoad); + if (list.length === 0) { + return; + } + + AsyncClient.getProfilesByIds(list); +} diff --git a/webapp/actions/global_actions.jsx b/webapp/actions/global_actions.jsx index 941aa34f47..23ff5a2952 100644 --- a/webapp/actions/global_actions.jsx +++ b/webapp/actions/global_actions.jsx @@ -12,7 +12,8 @@ import TeamStore from 'stores/team_store.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; import SearchStore from 'stores/search_store.jsx'; -import {handleNewPost} from 'actions/post_actions.jsx'; +import {handleNewPost, loadPosts, loadPostsBefore, loadPostsAfter} from 'actions/post_actions.jsx'; +import {loadProfilesAndTeamMembersForDMSidebar} from 'actions/user_actions.jsx'; import Constants from 'utils/constants.jsx'; const ActionTypes = Constants.ActionTypes; @@ -43,9 +44,9 @@ export function emitChannelClickEvent(channel) { function switchToChannel(chan) { AsyncClient.getChannels(true); AsyncClient.getMoreChannels(true); - AsyncClient.getChannelExtraInfo(chan.id); + AsyncClient.getChannelStats(chan.id); AsyncClient.updateLastViewedAt(chan.id); - AsyncClient.getPosts(chan.id); + loadPosts(chan.id); trackPage(); AppDispatcher.handleViewAction({ @@ -108,7 +109,7 @@ export function emitInitialLoad(callback) { if (data.team_members) { AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_TEAM_MEMBERS, + type: ActionTypes.RECEIVED_MY_TEAM_MEMBERS, team_members: data.team_members }); } @@ -143,9 +144,9 @@ export function doFocusPost(channelId, postId, data) { }); AsyncClient.getChannels(true); AsyncClient.getMoreChannels(true); - AsyncClient.getChannelExtraInfo(channelId); - AsyncClient.getPostsBefore(postId, 0, Constants.POST_FOCUS_CONTEXT_RADIUS, true); - AsyncClient.getPostsAfter(postId, 0, Constants.POST_FOCUS_CONTEXT_RADIUS, true); + AsyncClient.getChannelStats(channelId); + loadPostsBefore(postId, 0, Constants.POST_FOCUS_CONTEXT_RADIUS, true); + loadPostsAfter(postId, 0, Constants.POST_FOCUS_CONTEXT_RADIUS, true); } export function emitPostFocusEvent(postId, onSuccess) { @@ -246,14 +247,14 @@ export function emitLoadMorePostsFocusedTopEvent() { export function loadMorePostsTop(id, isFocusPost) { const earliestPostId = PostStore.getEarliestPost(id).id; if (PostStore.requestVisibilityIncrease(id, Constants.POST_CHUNK_SIZE)) { - AsyncClient.getPostsBefore(earliestPostId, 0, Constants.POST_CHUNK_SIZE, isFocusPost); + loadPostsBefore(earliestPostId, 0, Constants.POST_CHUNK_SIZE, isFocusPost); } } export function emitLoadMorePostsFocusedBottomEvent() { const id = PostStore.getFocusedPostId(); const latestPostId = PostStore.getLatestPost(id).id; - AsyncClient.getPostsAfter(latestPostId, 0, Constants.POST_CHUNK_SIZE, Boolean(id)); + loadPostsAfter(latestPostId, 0, Constants.POST_CHUNK_SIZE, Boolean(id)); } export function emitUserPostedEvent(post) { @@ -362,7 +363,7 @@ export function emitClearSuggestions(suggestionId) { export function emitPreferenceChangedEvent(preference) { if (preference.category === Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW) { - AsyncClient.getDirectProfiles(); + loadProfilesAndTeamMembersForDMSidebar(); } AppDispatcher.handleServerAction({ @@ -437,7 +438,7 @@ export function loadDefaultLocale() { export function viewLoggedIn() { AsyncClient.getChannels(); AsyncClient.getMoreChannels(); - AsyncClient.getChannelExtraInfo(); + AsyncClient.getChannelStats(); // Clear pending posts (shouldn't have pending posts if we are loading) PostStore.clearPendingPosts(); diff --git a/webapp/actions/integration_actions.jsx b/webapp/actions/integration_actions.jsx new file mode 100644 index 0000000000..5fd2b024d5 --- /dev/null +++ b/webapp/actions/integration_actions.jsx @@ -0,0 +1,114 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; + +import TeamStore from 'stores/team_store.jsx'; +import UserStore from 'stores/user_store.jsx'; + +import * as AsyncClient from 'utils/async_client.jsx'; +import Client from 'client/web_client.jsx'; + +import {ActionTypes} from 'utils/constants.jsx'; + +export function loadIncomingHooks() { + Client.listIncomingHooks( + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_INCOMING_WEBHOOKS, + teamId: TeamStore.getCurrentId(), + incomingWebhooks: data + }); + + loadProfilesForIncomingHooks(data); + }, + (err) => { + AsyncClient.dispatchError(err, 'listIncomingHooks'); + } + ); +} + +function loadProfilesForIncomingHooks(hooks) { + const profilesToLoad = {}; + for (let i = 0; i < hooks.length; i++) { + const hook = hooks[i]; + if (!UserStore.hasProfile(hook.user_id)) { + profilesToLoad[hook.user_id] = true; + } + } + + const list = Object.keys(profilesToLoad); + if (list.length === 0) { + return; + } + + AsyncClient.getProfilesByIds(list); +} + +export function loadOutgoingHooks() { + Client.listOutgoingHooks( + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_OUTGOING_WEBHOOKS, + teamId: TeamStore.getCurrentId(), + outgoingWebhooks: data + }); + + loadProfilesForOutgoingHooks(data); + }, + (err) => { + AsyncClient.dispatchError(err, 'listOutgoingHooks'); + } + ); +} + +function loadProfilesForOutgoingHooks(hooks) { + const profilesToLoad = {}; + for (let i = 0; i < hooks.length; i++) { + const hook = hooks[i]; + if (!UserStore.hasProfile(hook.creator_id)) { + profilesToLoad[hook.creator_id] = true; + } + } + + const list = Object.keys(profilesToLoad); + if (list.length === 0) { + return; + } + + AsyncClient.getProfilesByIds(list); +} + +export function loadTeamCommands() { + Client.listTeamCommands( + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_COMMANDS, + teamId: Client.teamId, + commands: data + }); + + loadProfilesForCommands(data); + }, + (err) => { + AsyncClient.dispatchError(err, 'loadTeamCommands'); + } + ); +} + +function loadProfilesForCommands(commands) { + const profilesToLoad = {}; + for (let i = 0; i < commands.length; i++) { + const command = commands[i]; + if (!UserStore.hasProfile(command.creator_id)) { + profilesToLoad[command.creator_id] = true; + } + } + + const list = Object.keys(profilesToLoad); + if (list.length === 0) { + return; + } + + AsyncClient.getProfilesByIds(list); +} diff --git a/webapp/actions/post_actions.jsx b/webapp/actions/post_actions.jsx index 63e3feec55..462576021a 100644 --- a/webapp/actions/post_actions.jsx +++ b/webapp/actions/post_actions.jsx @@ -8,6 +8,8 @@ import PostStore from 'stores/post_store.jsx'; import TeamStore from 'stores/team_store.jsx'; import UserStore from 'stores/user_store.jsx'; +import {loadStatusesForChannel} from 'actions/status_actions.jsx'; + import * as PostUtils from 'utils/post_utils.jsx'; import Client from 'client/web_client.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; @@ -52,6 +54,8 @@ export function handleNewPost(post, msg) { post, websocketMessageProps }); + + loadProfilesForPosts(data.posts); }, (err) => { AsyncClient.dispatchError(err, 'getPost'); @@ -115,7 +119,7 @@ export function setUnreadPost(channelId, postId) { member.last_viewed_at = lastViewed; member.msg_count = channel.total_msg_count - unreadPosts; member.mention_count = 0; - ChannelStore.setChannelMember(member); + ChannelStore.storeMyChannelMember(member); ChannelStore.setUnreadCount(channelId); AsyncClient.setLastViewedAt(lastViewed, channelId); } @@ -153,9 +157,156 @@ export function getFlaggedPosts() { results: data, is_flagged_posts: true }); + + loadProfilesForPosts(data.posts); }, (err) => { AsyncClient.dispatchError(err, 'getFlaggedPosts'); } ); } + +export function loadPosts(channelId = ChannelStore.getCurrentId()) { + const postList = PostStore.getAllPosts(channelId); + const latestPostTime = PostStore.getLatestPostFromPageTime(channelId); + + if (!postList || Object.keys(postList).length === 0 || postList.order.length < Constants.POST_CHUNK_SIZE || latestPostTime === 0) { + loadPostsPage(channelId, Constants.POST_CHUNK_SIZE); + return; + } + + Client.getPosts( + channelId, + latestPostTime, + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_POSTS, + id: channelId, + before: true, + numRequested: 0, + post_list: data + }); + + loadProfilesForPosts(data.posts); + loadStatusesForChannel(channelId); + }, + (err) => { + AsyncClient.dispatchError(err, 'loadPosts'); + } + ); +} + +export function loadPostsPage(channelId = ChannelStore.getCurrentId(), max = Constants.POST_CHUNK_SIZE) { + const postList = PostStore.getAllPosts(channelId); + + // if we already have more than POST_CHUNK_SIZE posts, + // let's get the amount we have but rounded up to next multiple of POST_CHUNK_SIZE, + // with a max + let numPosts = Math.min(max, Constants.POST_CHUNK_SIZE); + if (postList && postList.order.length > 0) { + numPosts = Math.min(max, Constants.POST_CHUNK_SIZE * Math.ceil(postList.order.length / Constants.POST_CHUNK_SIZE)); + } + + Client.getPostsPage( + channelId, + 0, + numPosts, + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_POSTS, + id: channelId, + before: true, + numRequested: numPosts, + checkLatest: true, + post_list: data + }); + + loadProfilesForPosts(data.posts); + loadStatusesForChannel(channelId); + }, + (err) => { + AsyncClient.dispatchError(err, 'loadPostsPage'); + } + ); +} + +export function loadPostsBefore(postId, offset, numPost, isPost) { + const channelId = ChannelStore.getCurrentId(); + if (channelId == null) { + return; + } + + Client.getPostsBefore( + channelId, + postId, + offset, + numPost, + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_POSTS, + id: channelId, + before: true, + numRequested: numPost, + post_list: data, + isPost + }); + + loadProfilesForPosts(data.posts); + loadStatusesForChannel(channelId); + }, + (err) => { + AsyncClient.dispatchError(err, 'loadPostsBefore'); + } + ); +} + +export function loadPostsAfter(postId, offset, numPost, isPost) { + const channelId = ChannelStore.getCurrentId(); + if (channelId == null) { + return; + } + + Client.getPostsAfter( + channelId, + postId, + offset, + numPost, + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_POSTS, + id: channelId, + before: false, + numRequested: numPost, + post_list: data, + isPost + }); + + loadProfilesForPosts(data.posts); + loadStatusesForChannel(channelId); + }, + (err) => { + AsyncClient.dispatchError(err, 'loadPostsAfter'); + } + ); +} + +function loadProfilesForPosts(posts) { + const profilesToLoad = {}; + for (const pid in posts) { + if (!posts.hasOwnProperty(pid)) { + continue; + } + + const post = posts[pid]; + if (!UserStore.hasProfile(post.user_id)) { + profilesToLoad[post.user_id] = true; + } + } + + const list = Object.keys(profilesToLoad); + if (list.length === 0) { + return; + } + + AsyncClient.getProfilesByIds(list); +} diff --git a/webapp/actions/status_actions.jsx b/webapp/actions/status_actions.jsx new file mode 100644 index 0000000000..c198c52ace --- /dev/null +++ b/webapp/actions/status_actions.jsx @@ -0,0 +1,133 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; + +import ChannelStore from 'stores/channel_store.jsx'; +import PostStore from 'stores/post_store.jsx'; +import PreferenceStore from 'stores/preference_store.jsx'; + +import Client from 'client/web_client.jsx'; + +import {ActionTypes, Preferences, Constants} from 'utils/constants.jsx'; + +export function loadStatusesForChannel(channelId = ChannelStore.getCurrentId()) { + const postList = PostStore.getVisiblePosts(channelId); + if (!postList || !postList.posts) { + return; + } + + const statusesToLoad = {}; + for (const pid in postList.posts) { + if (!postList.posts.hasOwnProperty(pid)) { + continue; + } + + const post = postList.posts[pid]; + statusesToLoad[post.user_id] = true; + } + + loadStatusesByIds(Object.keys(statusesToLoad)); +} + +export function loadStatusesForDMSidebar() { + const dmPrefs = PreferenceStore.getCategory(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW); + const statusesToLoad = []; + + for (const [key, value] of dmPrefs) { + if (value === 'true') { + statusesToLoad.push(key); + } + } + + loadStatusesByIds(statusesToLoad); +} + +export function loadStatusesForChannelAndSidebar() { + const statusesToLoad = {}; + + const channelId = ChannelStore.getCurrentId(); + const postList = PostStore.getVisiblePosts(channelId); + if (postList && postList.posts) { + for (const pid in postList.posts) { + if (!postList.posts.hasOwnProperty(pid)) { + continue; + } + + const post = postList.posts[pid]; + statusesToLoad[post.user_id] = true; + } + } + + const dmPrefs = PreferenceStore.getCategory(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW); + + for (const [key, value] of dmPrefs) { + if (value === 'true') { + statusesToLoad[key] = true; + } + } + + loadStatusesByIds(Object.keys(statusesToLoad)); +} + +export function loadStatusesForProfilesList(users) { + if (users == null) { + return; + } + + const statusesToLoad = []; + for (let i = 0; i < users.length; i++) { + statusesToLoad.push(users[i].id); + } + + loadStatusesByIds(statusesToLoad); +} + +export function loadStatusesForProfilesMap(users) { + if (users == null) { + return; + } + + const statusesToLoad = []; + for (const userId in users) { + if (!users.hasOwnProperty(userId)) { + return; + } + statusesToLoad.push(userId); + } + + loadStatusesByIds(statusesToLoad); +} + +export function loadStatusesByIds(userIds) { + if (userIds.length === 0) { + return; + } + + Client.getStatusesByIds( + userIds, + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_STATUSES, + statuses: data + }); + } + ); +} + +let intervalId = ''; + +export function startPeriodicStatusUpdates() { + clearInterval(intervalId); + + intervalId = setInterval( + () => { + loadStatusesForChannelAndSidebar(); + }, + Constants.STATUS_INTERVAL + ); +} + +export function stopPeriodicStatusUpdates() { + clearInterval(intervalId); +} diff --git a/webapp/actions/team_actions.jsx b/webapp/actions/team_actions.jsx index 3bf25c193f..e0403529e2 100644 --- a/webapp/actions/team_actions.jsx +++ b/webapp/actions/team_actions.jsx @@ -2,6 +2,7 @@ // See License.txt for license information. import UserStore from 'stores/user_store.jsx'; +import TeamStore from 'stores/team_store.jsx'; import Constants from 'utils/constants.jsx'; const ActionTypes = Constants.ActionTypes; @@ -19,8 +20,6 @@ export function checkIfTeamExists(teamName, onSuccess, onError) { export function createTeam(team, onSuccess, onError) { Client.createTeam(team, (rteam) => { - AsyncClient.getDirectProfiles(); - AppDispatcher.handleServerAction({ type: ActionTypes.CREATED_TEAM, team: rteam, @@ -36,3 +35,25 @@ export function createTeam(team, onSuccess, onError) { onError ); } + +export function removeUserFromTeam(teamId, userId, success, error) { + Client.removeUserFromTeam( + teamId, + userId, + () => { + TeamStore.removeMemberInTeam(teamId, userId); + AsyncClient.getUser(userId); + + if (success) { + success(); + } + }, + (err) => { + AsyncClient.dispatchError(err, 'removeUserFromTeam'); + + if (error) { + error(err); + } + } + ); +} diff --git a/webapp/actions/user_actions.jsx b/webapp/actions/user_actions.jsx index 2d5fd805ce..900353701d 100644 --- a/webapp/actions/user_actions.jsx +++ b/webapp/actions/user_actions.jsx @@ -2,12 +2,17 @@ // See License.txt for license information. import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; -import * as AsyncClient from 'utils/async_client.jsx'; -import Client from 'client/web_client.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; import TeamStore from 'stores/team_store.jsx'; import UserStore from 'stores/user_store.jsx'; +import ChannelStore from 'stores/channel_store.jsx'; + +import {loadStatusesForProfilesList, loadStatusesForProfilesMap} from 'actions/status_actions.jsx'; + +import {getDirectChannelName} from 'utils/utils.jsx'; +import * as AsyncClient from 'utils/async_client.jsx'; +import Client from 'client/web_client.jsx'; import {ActionTypes, Preferences} from 'utils/constants.jsx'; @@ -29,9 +34,179 @@ export function switchFromLdapToEmail(email, password, ldapPassword, onSuccess, ); } -export function getMoreDmList() { - AsyncClient.getTeamMembers(TeamStore.getCurrentId()); - AsyncClient.getProfilesForDirectMessageList(); +export function loadProfilesAndTeamMembers(offset, limit, teamId = TeamStore.getCurrentId(), success, error) { + Client.getProfilesInTeam( + teamId, + offset, + limit, + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_PROFILES_IN_TEAM, + profiles: data, + team_id: teamId, + offset, + count: Object.keys(data).length + }); + + loadTeamMembersForProfilesMap(data, teamId, success, error); + loadStatusesForProfilesMap(data); + }, + (err) => { + AsyncClient.dispatchError(err, 'getProfilesInTeam'); + } + ); +} + +export function loadTeamMembersForProfilesMap(profiles, teamId = TeamStore.getCurrentId(), success, error) { + const membersToLoad = {}; + for (const pid in profiles) { + if (!profiles.hasOwnProperty(pid)) { + continue; + } + + if (!TeamStore.hasActiveMemberInTeam(teamId, pid)) { + membersToLoad[pid] = true; + } + } + + const list = Object.keys(membersToLoad); + if (list.length === 0) { + if (success) { + success({}); + } + return; + } + + loadTeamMembersForProfiles(list, teamId, success, error); +} + +export function loadTeamMembersForProfilesList(profiles, teamId = TeamStore.getCurrentId(), success, error) { + const membersToLoad = {}; + for (let i = 0; i < profiles.length; i++) { + const pid = profiles[i].id; + + if (!TeamStore.hasActiveMemberInTeam(teamId, pid)) { + membersToLoad[pid] = true; + } + } + + const list = Object.keys(membersToLoad); + if (list.length === 0) { + if (success) { + success({}); + } + return; + } + + loadTeamMembersForProfiles(list, teamId, success, error); +} + +function loadTeamMembersForProfiles(userIds, teamId, success, error) { + Client.getTeamMembersByIds( + teamId, + userIds, + (data) => { + const memberMap = {}; + for (let i = 0; i < data.length; i++) { + memberMap[data[i].user_id] = data[i]; + } + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_MEMBERS_IN_TEAM, + team_id: teamId, + team_members: memberMap + }); + + if (success) { + success(data); + } + }, + (err) => { + AsyncClient.dispatchError(err, 'getTeamMembersByIds'); + + if (error) { + error(err); + } + } + ); +} + +function populateDMChannelsWithProfiles(userIds) { + const currentUserId = UserStore.getCurrentId(); + + for (let i = 0; i < userIds.length; i++) { + const channelName = getDirectChannelName(currentUserId, userIds[i]); + const channel = ChannelStore.getByName(channelName); + if (channel) { + UserStore.saveUserIdInChannel(channel.id, userIds[i]); + } + } +} + +export function loadProfilesAndTeamMembersForDMSidebar() { + const dmPrefs = PreferenceStore.getCategory(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW); + const teamId = TeamStore.getCurrentId(); + const profilesToLoad = []; + const membersToLoad = []; + + for (const [key, value] of dmPrefs) { + if (value === 'true') { + if (!UserStore.hasProfile(key)) { + profilesToLoad.push(key); + } + membersToLoad.push(key); + } + } + + if (profilesToLoad.length > 0) { + Client.getProfilesByIds( + profilesToLoad, + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_PROFILES, + profiles: data + }); + + // Use membersToLoad so we get all the DM profiles even if they were already loaded + populateDMChannelsWithProfiles(membersToLoad); + }, + (err) => { + AsyncClient.dispatchError(err, 'getProfilesByIds'); + } + ); + } else { + populateDMChannelsWithProfiles(membersToLoad); + } + + if (membersToLoad.length > 0) { + Client.getTeamMembersByIds( + teamId, + membersToLoad, + (data) => { + const memberMap = {}; + for (let i = 0; i < data.length; i++) { + memberMap[data[i].user_id] = data[i]; + } + + const nonMembersMap = {}; + for (let i = 0; i < membersToLoad.length; i++) { + if (!memberMap[membersToLoad[i]]) { + nonMembersMap[membersToLoad[i]] = true; + } + } + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_MEMBERS_IN_TEAM, + team_id: teamId, + team_members: memberMap, + non_team_members: nonMembersMap + }); + }, + (err) => { + AsyncClient.dispatchError(err, 'getTeamMembersByIds'); + } + ); + } } export function saveTheme(teamId, theme, onSuccess, onError) { @@ -82,3 +257,62 @@ function onThemeSaved(teamId, theme, onSuccess) { onSuccess(); } + +export function searchUsers(term, teamId = TeamStore.getCurrentId(), options = {}, success, error) { + Client.searchUsers( + term, + teamId, + options, + (data) => { + loadStatusesForProfilesList(data); + + if (success) { + success(data); + } + }, + (err) => { + AsyncClient.dispatchError(err, 'searchUsers'); + + if (error) { + error(err); + } + } + ); +} + +export function autocompleteUsersInChannel(username, channelId, success, error) { + Client.autocompleteUsersInChannel( + username, + channelId, + (data) => { + if (success) { + success(data); + } + }, + (err) => { + AsyncClient.dispatchError(err, 'autocompleteUsersInChannel'); + + if (error) { + error(err); + } + } + ); +} + +export function autocompleteUsersInTeam(username, success, error) { + Client.autocompleteUsersInTeam( + username, + (data) => { + if (success) { + success(data); + } + }, + (err) => { + AsyncClient.dispatchError(err, 'autocompleteUsersInTeam'); + + if (error) { + error(err); + } + } + ); +} diff --git a/webapp/actions/websocket_actions.jsx b/webapp/actions/websocket_actions.jsx index 08449b87e8..14a1506928 100644 --- a/webapp/actions/websocket_actions.jsx +++ b/webapp/actions/websocket_actions.jsx @@ -3,8 +3,6 @@ import $ from 'jquery'; -import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; - import UserStore from 'stores/user_store.jsx'; import TeamStore from 'stores/team_store.jsx'; import PostStore from 'stores/post_store.jsx'; @@ -20,10 +18,11 @@ import * as Utils from 'utils/utils.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; -import * as UserActions from 'actions/user_actions.jsx'; -import {handleNewPost} from 'actions/post_actions.jsx'; +import {handleNewPost, loadPosts} from 'actions/post_actions.jsx'; +import {loadProfilesAndTeamMembersForDMSidebar} from 'actions/user_actions.jsx'; +import * as StatusActions from 'actions/status_actions.jsx'; -import {Constants, SocketEvents, ActionTypes} from 'utils/constants.jsx'; +import {Constants, SocketEvents, UserStatuses} from 'utils/constants.jsx'; import {browserHistory} from 'react-router/es6'; @@ -53,6 +52,7 @@ export function initialize() { connUrl += Client.getUsersRoute() + '/websocket'; WebSocketClient.setEventCallback(handleEvent); + WebSocketClient.setFirstConnectCallback(handleFirstConnect); WebSocketClient.setReconnectCallback(handleReconnect); WebSocketClient.setCloseCallback(handleClose); WebSocketClient.initialize(connUrl); @@ -64,22 +64,19 @@ export function close() { } export function getStatuses() { - WebSocketClient.getStatuses( - (resp) => { - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_STATUSES, - statuses: resp.data - }); - } - ); + StatusActions.loadStatusesForChannelAndSidebar(); +} + +function handleFirstConnect() { + getStatuses(); + ErrorStore.clearLastError(); + ErrorStore.emitChange(); } function handleReconnect() { if (Client.teamId) { AsyncClient.getChannels(); - AsyncClient.getPosts(ChannelStore.getCurrentId()); - AsyncClient.getTeamMembers(TeamStore.getCurrentId()); - AsyncClient.getProfiles(); + loadPosts(ChannelStore.getCurrentId()); } getStatuses(); @@ -112,7 +109,7 @@ function handleEvent(msg) { break; case SocketEvents.NEW_USER: - handleNewUserEvent(); + handleNewUserEvent(msg); break; case SocketEvents.LEAVE_TEAM: @@ -170,6 +167,10 @@ function handleEvent(msg) { function handleNewPostEvent(msg) { const post = JSON.parse(msg.data.post); handleNewPost(post, msg); + + if (UserStore.getStatus(post.user_id) !== UserStatuses.ONLINE) { + StatusActions.loadStatusesByIds([post.user_id]); + } } function handlePostEditEvent(msg) { @@ -196,36 +197,33 @@ function handlePostDeleteEvent(msg) { } } -function handleNewUserEvent() { - AsyncClient.getTeamMembers(TeamStore.getCurrentId()); - AsyncClient.getProfiles(); - AsyncClient.getDirectProfiles(); - AsyncClient.getChannelExtraInfo(); +function handleNewUserEvent(msg) { + AsyncClient.getUser(msg.user_id); + AsyncClient.getChannelStats(); + loadProfilesAndTeamMembersForDMSidebar(); } function handleLeaveTeamEvent(msg) { if (UserStore.getCurrentId() === msg.data.user_id) { - TeamStore.removeTeamMember(msg.broadcast.team_id); + TeamStore.removeMyTeamMember(msg.broadcast.team_id); - // if the are on the team begin removed redirect them to the root + // if they are on the team being removed redirect them to the root if (TeamStore.getCurrentId() === msg.broadcast.team_id) { TeamStore.setCurrentId(''); Client.setTeamId(''); browserHistory.push('/'); } - } else if (TeamStore.getCurrentId() === msg.broadcast.team_id) { - UserActions.getMoreDmList(); } } function handleDirectAddedEvent(msg) { AsyncClient.getChannel(msg.broadcast.channel_id); - AsyncClient.getDirectProfiles(); + loadProfilesAndTeamMembersForDMSidebar(); } function handleUserAddedEvent(msg) { if (ChannelStore.getCurrentId() === msg.broadcast.channel_id) { - AsyncClient.getChannelExtraInfo(); + AsyncClient.getChannelStats(); } if (TeamStore.getCurrentId() === msg.data.team_id && UserStore.getCurrentId() === msg.data.user_id) { @@ -248,7 +246,7 @@ function handleUserRemovedEvent(msg) { $('#removed_from_channel').modal('show'); } } else if (ChannelStore.getCurrentId() === msg.broadcast.channel_id) { - AsyncClient.getChannelExtraInfo(); + AsyncClient.getChannelStats(); } } @@ -287,6 +285,10 @@ function handlePreferenceChangedEvent(msg) { function handleUserTypingEvent(msg) { GlobalActions.emitRemoteUserTypingEvent(msg.broadcast.channel_id, msg.data.user_id, msg.data.parent_id); + + if (UserStore.getStatus(msg.data.user_id) !== UserStatuses.ONLINE) { + StatusActions.loadStatusesByIds([msg.data.user_id]); + } } function handleStatusChangedEvent(msg) { @@ -301,4 +303,4 @@ function handleHelloEvent(msg) { function handleWebrtc(msg) { const data = msg.data; return WebrtcActions.handle(data); -} \ No newline at end of file +} diff --git a/webapp/client/client.jsx b/webapp/client/client.jsx index 334f8374d8..596242e414 100644 --- a/webapp/client/client.jsx +++ b/webapp/client/client.jsx @@ -73,11 +73,7 @@ export default class Client { return `${this.url}${this.urlVersion}/teams`; } - getTeamNeededRoute() { - return `${this.url}${this.urlVersion}/teams/${this.getTeamId()}`; - } - - getTeamNeededManualRoute(teamId) { + getTeamNeededRoute(teamId = this.getTeamId()) { return `${this.url}${this.urlVersion}/teams/${teamId}`; } @@ -565,15 +561,43 @@ export default class Client { end(this.handleResponse.bind(this, 'getMyTeam', success, error)); } - getTeamMembers(teamId, success, error) { + getTeamMembers(teamId, offset, limit, success, error) { request. - get(`${this.getTeamsRoute()}/members/${teamId}`). + get(`${this.getTeamNeededRoute(teamId)}/members/${offset}/${limit}`). set(this.defaultHeaders). type('application/json'). accept('application/json'). end(this.handleResponse.bind(this, 'getTeamMembers', success, error)); } + getTeamMember(teamId, userId, success, error) { + request. + get(`${this.getTeamNeededRoute(teamId)}/members/${userId}`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + end(this.handleResponse.bind(this, 'getTeamMember', success, error)); + } + + getTeamMembersByIds(teamId, userIds, success, error) { + request. + post(`${this.getTeamNeededRoute(teamId)}/members/ids`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + send(userIds). + end(this.handleResponse.bind(this, 'getTeamMembersByIds', success, error)); + } + + getTeamStats(teamId, success, error) { + request. + get(`${this.getTeamNeededRoute(teamId)}/stats`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + end(this.handleResponse.bind(this, 'getTeamStats', success, error)); + } + inviteMembers(data, success, error) { request. post(`${this.getTeamNeededRoute()}/invite_members`). @@ -740,7 +764,7 @@ export default class Client { }; request. - post(`${this.getTeamNeededManualRoute(teamId)}/update_member_roles`). + post(`${this.getTeamNeededRoute(teamId)}/update_member_roles`). set(this.defaultHeaders). type('application/json'). accept('application/json'). @@ -1003,40 +1027,78 @@ export default class Client { end(this.handleResponse.bind(this, 'getRecentlyActiveUsers', success, error)); } - getDirectProfiles(success, error) { + getProfiles(offset, limit, success, error) { request. - get(`${this.getUsersRoute()}/direct_profiles`). - set(this.defaultHeaders). - type('application/json'). - accept('application/json'). - end(this.handleResponse.bind(this, 'getDirectProfiles', success, error)); - } - - getProfiles(success, error) { - request. - get(`${this.getUsersRoute()}/profiles/${this.getTeamId()}`). + get(`${this.getUsersRoute()}/${offset}/${limit}`). set(this.defaultHeaders). type('application/json'). accept('application/json'). end(this.handleResponse.bind(this, 'getProfiles', success, error)); } - getProfilesForTeam(teamId, success, error) { + getProfilesInTeam(teamId, offset, limit, success, error) { request. - get(`${this.getUsersRoute()}/profiles/${teamId}`). + get(`${this.getTeamNeededRoute(teamId)}/users/${offset}/${limit}`). set(this.defaultHeaders). type('application/json'). accept('application/json'). - end(this.handleResponse.bind(this, 'getProfilesForTeam', success, error)); + end(this.handleResponse.bind(this, 'getProfilesInTeam', success, error)); } - getProfilesForDirectMessageList(success, error) { + getProfilesInChannel(channelId, offset, limit, success, error) { request. - get(`${this.getUsersRoute()}/profiles_for_dm_list/${this.getTeamId()}`). + get(`${this.getChannelNeededRoute(channelId)}/users/${offset}/${limit}`). set(this.defaultHeaders). type('application/json'). accept('application/json'). - end(this.handleResponse.bind(this, 'getProfilesForDirectMessageList', success, error)); + end(this.handleResponse.bind(this, 'getProfilesInChannel', success, error)); + } + + getProfilesNotInChannel(channelId, offset, limit, success, error) { + request. + get(`${this.getChannelNeededRoute(channelId)}/users/not_in_channel/${offset}/${limit}`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + end(this.handleResponse.bind(this, 'getProfilesNotInChannel', success, error)); + } + + getProfilesByIds(userIds, success, error) { + request. + post(`${this.getUsersRoute()}/ids`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + send(userIds). + end(this.handleResponse.bind(this, 'getProfilesByIds', success, error)); + } + + searchUsers(term, teamId, options, success, error) { + request. + post(`${this.getUsersRoute()}/search`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + send({term, team_id: teamId, ...options}). + end(this.handleResponse.bind(this, 'searchUsers', success, error)); + } + + autocompleteUsersInChannel(term, channelId, success, error) { + request. + get(`${this.getChannelNeededRoute(channelId)}/users/autocomplete?term=${encodeURIComponent(term)}`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + end(this.handleResponse.bind(this, 'autocompleteUsers', success, error)); + } + + autocompleteUsersInTeam(term, success, error) { + request. + get(`${this.getTeamNeededRoute()}/users/autocomplete?term=${encodeURIComponent(term)}`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + end(this.handleResponse.bind(this, 'autocompleteUsers', success, error)); } getStatuses(success, error) { @@ -1048,6 +1110,16 @@ export default class Client { end(this.handleResponse.bind(this, 'getStatuses', success, error)); } + getStatusesByIds(userIds, success, error) { + request. + post(`${this.getUsersRoute()}/status/ids`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + send(userIds). + end(this.handleResponse.bind(this, 'getStatuses', success, error)); + } + setActiveChannel(id, success, error) { request. post(`${this.getUsersRoute()}/status/set_active_channel`). @@ -1285,18 +1357,22 @@ export default class Client { end(this.handleResponse.bind(this, 'getChannelCounts', success, error)); } - getChannelExtraInfo(channelId, memberLimit, success, error) { - var url = `${this.getChannelNeededRoute(channelId)}/extra_info`; - if (memberLimit) { - url += '/' + memberLimit; - } - + getChannelStats(channelId, success, error) { request. - get(url). + get(`${this.getChannelNeededRoute(channelId)}/stats`). set(this.defaultHeaders). type('application/json'). accept('application/json'). - end(this.handleResponse.bind(this, 'getChannelExtraInfo', success, error)); + end(this.handleResponse.bind(this, 'getChannelStats', success, error)); + } + + getChannelMember(channelId, userId, success, error) { + request. + get(`${this.getChannelNeededRoute(channelId)}/members/${userId}`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + end(this.handleResponse.bind(this, 'getChannelMember', success, error)); } addChannelMember(channelId, userId, success, error) { diff --git a/webapp/client/websocket_client.jsx b/webapp/client/websocket_client.jsx index aa78d8d98b..035e30be55 100644 --- a/webapp/client/websocket_client.jsx +++ b/webapp/client/websocket_client.jsx @@ -12,6 +12,7 @@ export default class WebSocketClient { this.connectFailCount = 0; this.eventCallback = null; this.responseCallbacks = {}; + this.firstConnectCallback = null; this.reconnectCallback = null; this.errorCallback = null; this.closeCallback = null; @@ -29,12 +30,13 @@ export default class WebSocketClient { this.conn = new WebSocket(connectionUrl); this.conn.onopen = () => { - if (this.reconnectCallback) { - this.reconnectCallback(); - } - if (this.connectFailCount > 0) { console.log('websocket re-established connection'); //eslint-disable-line no-console + if (this.reconnectCallback) { + this.reconnectCallback(); + } + } else if (this.firstConnectCallback) { + this.firstConnectCallback(); } this.connectFailCount = 0; @@ -104,6 +106,10 @@ export default class WebSocketClient { this.eventCallback = callback; } + setFirstConnectCallback(callback) { + this.firstConnectCallback = callback; + } + setReconnectCallback(callback) { this.reconnectCallback = callback; } @@ -157,4 +163,10 @@ export default class WebSocketClient { getStatuses(callback) { this.sendMessage('get_statuses', null, callback); } + + getStatusesByIds(userIds, callback) { + const data = {}; + data.user_ids = userIds; + this.sendMessage('get_statuses_by_ids', data, callback); + } } diff --git a/webapp/components/admin_console/admin_navbar_dropdown.jsx b/webapp/components/admin_console/admin_navbar_dropdown.jsx index 7b958cbb00..f20451b4b3 100644 --- a/webapp/components/admin_console/admin_navbar_dropdown.jsx +++ b/webapp/components/admin_console/admin_navbar_dropdown.jsx @@ -22,7 +22,7 @@ export default class AdminNavbarDropdown extends React.Component { this.state = { teams: TeamStore.getAll(), - teamMembers: TeamStore.getTeamMembers() + teamMembers: TeamStore.getMyTeamMembers() }; } @@ -45,7 +45,7 @@ export default class AdminNavbarDropdown extends React.Component { onTeamChange() { this.setState({ teams: TeamStore.getAll(), - teamMembers: TeamStore.getTeamMembers() + teamMembers: TeamStore.getMyTeamMembers() }); } diff --git a/webapp/components/admin_console/user_item.jsx b/webapp/components/admin_console/admin_team_members_dropdown.jsx similarity index 80% rename from webapp/components/admin_console/user_item.jsx rename to webapp/components/admin_console/admin_team_members_dropdown.jsx index ac548afe01..85daa86ba0 100644 --- a/webapp/components/admin_console/user_item.jsx +++ b/webapp/components/admin_console/admin_team_members_dropdown.jsx @@ -8,11 +8,11 @@ import UserStore from 'stores/user_store.jsx'; import ConfirmModal from '../confirm_modal.jsx'; import TeamStore from 'stores/team_store.jsx'; -import {FormattedMessage, FormattedHTMLMessage} from 'react-intl'; +import {FormattedMessage} from 'react-intl'; import React from 'react'; -export default class UserItem extends React.Component { +export default class AdminTeamMembersDropdown extends React.Component { constructor(props) { super(props); @@ -50,7 +50,7 @@ export default class UserItem extends React.Component { } ); Client.updateTeamMemberRoles( - this.props.team.id, + this.props.teamMember.team_id, this.props.user.id, 'team_user', () => { @@ -74,7 +74,7 @@ export default class UserItem extends React.Component { handleRemoveFromTeam() { Client.removeUserFromTeam( - this.props.team.id, + this.props.teamMember.team_id, this.props.user.id, () => { this.props.refreshProfiles(); @@ -111,7 +111,7 @@ export default class UserItem extends React.Component { doMakeTeamAdmin() { Client.updateTeamMemberRoles( - this.props.team.id, + this.props.teamMember.team_id, this.props.user.id, 'team_user team_admin', () => { @@ -241,7 +241,6 @@ export default class UserItem extends React.Component { } const me = UserStore.getCurrentUser(); - const email = user.email; let showMakeMember = Utils.isAdmin(teamMember.roles) || Utils.isSystemAdmin(user.roles); let showMakeAdmin = !Utils.isAdmin(teamMember.roles) && !Utils.isSystemAdmin(user.roles); let showMakeSystemAdmin = !Utils.isSystemAdmin(user.roles); @@ -406,39 +405,8 @@ export default class UserItem extends React.Component { ); } - let mfaActiveText; - if (mfaEnabled) { - if (user.mfa_active) { - mfaActiveText = ( -